diff --git a/docs/python-web.md b/docs/python-web.md new file mode 100644 index 0000000..d1e6db7 --- /dev/null +++ b/docs/python-web.md @@ -0,0 +1,242 @@ +# Cross-Platform Python Companion for Flutter + +This guide explains how to create and use a Python companion application that works across different platforms (Web, Desktop) using the serious_python package. + +## Setup + +### Project Structure +``` +your_flutter_project/ +├── python_companion/ +│ ├── desktop/ +│ │ ├── __init__.py +│ │ └── companion_server.py # Flask server for desktop +│ ├── web/ +│ │ ├── __init__.py +│ │ └── command_handler.py # Command handling for web +│ ├── functionality/ +│ │ ├── __init__.py +│ │ └── your_functions.py # Shared functionality +│ ├── requirements/ +│ │ ├── base.txt # Shared dependencies +│ │ ├── desktop.txt # Desktop-specific requirements +│ │ └── web.txt # Web-specific (Pyodide) requirements +│ ├── python_companion_desktop.py # Desktop entry point +│ └── python_companion_web.py # Web entry point +``` + +### Requirements Files + +```txt +# requirements/base.txt +numpy>=1.20.0 +scipy>=1.7.0 + +# requirements/web.txt +-r base.txt +# Only Pyodide-compatible versions +h5py==3.8.0 + +# requirements/desktop.txt +-r base.txt +flask>=2.0.0 +h5py==3.9.0 +``` + +### Implementation + +1. Desktop Implementation (Flask Server): +```python +# desktop/companion_server.py +from flask import Flask, request, jsonify +import functionality + +app = Flask(__name__) + +@app.route('/your_endpoint', methods=['POST']) +def your_endpoint(): + result = functionality.your_function(request.json) + return jsonify(result) + +def run_server(): + app.run(port=50001, debug=False, use_reloader=False) +``` + +2. Web Implementation (Pyodide): +```python +# web/command_handler.py +import json +import functionality + +_command_functions = { + "your_command": lambda data: functionality.your_function(data), +} + +def handle_command(command: str, data): + command_function = _command_functions.get(command) + try: + loaded_data = json.loads(data) + except: + loaded_data = data + return command_function(data) +``` + +3. Shared Functionality: +```python +# functionality/your_functions.py + +def your_function(json_data): + # Your implementation + return {"result": "success"} +``` + +4. Entry Points: +```python +# python_companion_desktop.py +from desktop import run_server + +if __name__ == '__main__': + run_server() + +# python_companion_web.py +import os +from web import handle_command + +if __name__ == '__main__': + command = os.environ.get("PYODIDE_COMMAND", "") + data = os.environ.get("PYODIDE_DATA", None) + pyodide_result = handle_command(command, data) +``` + +### Packaging + +Package your Python companion for different platforms: + +```bash +# For Web (Pyodide) +dart run serious_python:main package \ + --asset assets/python_companion.zip python_companion/ \ + -p Pyodide \ + --requirements "-r,python_companion/requirements/web.txt" + +# For Desktop (Linux) +dart run serious_python:main package \ + --asset assets/python_companion.zip python_companion/ \ + -p Linux \ + --requirements "-r,python_companion/requirements/desktop.txt" +``` + +## Usage in Flutter + +1. Add serious_python to your pubspec.yaml: +```yaml +dependencies: + serious_python: ^latest_version +``` + +2. Create a service class: +```dart +class PythonCompanionService { + Future>> callPythonFunction(List data); +} + +class PythonCompanionServiceWeb implements PythonCompanionService { + Future>> callPythonFunction(List data) async { + final String? result = await SeriousPython.run( + 'assets/python_companion.zip', + appFileName: 'python_companion_web.py', + modulePaths: ['python_companion/web', 'python_companion/functionality'], + environmentVariables: + { + 'PYODIDE_COMMAND': 'your_command', + 'PYODIDE_DATA': jsonEncode({'data': data}) + }, + sync: true, + ); + + if (result == null || result.isEmpty) { + return left(Exception('Failed to execute Python function')); + } + + return right(jsonDecode(result)); + } +} + +class PythonCompanionServiceDesktop implements PythonCompanionService { + @override + Future startServer() async { + try { + // Start the Python server + await SeriousPython.run( + 'assets/python_companion.zip', + appFileName: 'python_companion_desktop.py', + ); + + // Wait for server to be ready, e.g. by checking the health endpoint + await _waitForServer(); + _isServerRunning = true; + } catch (e) { + throw Exception('Failed to start Python server: $e'); + } + } + + @override + Future>> callPythonFunction(List data) async { + try { + final response = await http.post( + Uri.parse('$_baseUrl/your_endpoint'), + headers: {'Content-Type': 'application/json'}, + body: jsonEncode({'data': data}), + ); + + if (response.statusCode != 200) { + return left(Exception('Server error: ${response.statusCode}')); + } + + return right(jsonDecode(response.body)); + } catch (e) { + return left(Exception('Error calling Python function: $e')); + } + } +} +``` + +3. Use in your Flutter app: +```dart +final pythonService = PythonCompanionService(); + +void yourFunction() async { + final result = await pythonService.callPythonFunction([1.0, 2.0, 3.0]); + result.fold( + (error) => print('Error: $error'), + (success) => print('Success: $success'), + ); +} +``` + +## Important Notes + +1. **Web Compatibility**: Ensure all Python packages used in web implementation are [Pyodide-compatible](https://pyodide.org/en/stable/usage/packages-in-pyodide.html). + +2. **Package Versions**: Use platform-specific package versions when needed (e.g., different h5py versions for web and desktop). + +3. **Error Handling**: Implement proper error handling in both Python and Dart code. + +4. **Data Transfer**: Use JSON for data transfer between Flutter and Python. + +5. **Resource Management**: Properly manage resources (close files, connections, etc.). + +## Troubleshooting + +1. **Module Import Issues**: Ensure correct module paths and dependencies. +2. **Platform Compatibility**: Check package compatibility for each platform. +3. **Port Conflicts**: For desktop, ensure the Flask server port (50001) is available. +4. **Memory Management**: Be mindful of memory usage, especially with large data operations. + +## Best Practices + +1. Keep shared functionality platform-independent +2. Implement proper error handling and logging +3. Use type hints and documentation +4. Follow platform-specific conventions +5. Test on all target platforms diff --git a/src/serious_python/README.md b/src/serious_python/README.md index 5e28907..4b7c1e4 100644 --- a/src/serious_python/README.md +++ b/src/serious_python/README.md @@ -10,9 +10,9 @@ Serious Python is part of [Flet](https://flet.dev) project - the fastest way to ## Platform Support -| iOS | Android | macOS | Linux | Windows | -| :-----: | :----------: | :---------: | :-------: | :----------: | -| ✅ | ✅ | ✅ | ✅ | ✅ | +| iOS | Android | macOS | Linux | Windows | Web | +|:---:|:-------:|:-----:|:-----:|:-------:|:---:| +| ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ### Python versions diff --git a/src/serious_python/lib/serious_python.dart b/src/serious_python/lib/serious_python.dart index 968d35f..d5d50fe 100644 --- a/src/serious_python/lib/serious_python.dart +++ b/src/serious_python/lib/serious_python.dart @@ -1,8 +1,10 @@ -import 'dart:io'; - +import 'package:flutter/foundation.dart'; import 'package:path/path.dart' as path; import 'package:serious_python_platform_interface/serious_python_platform_interface.dart'; +// Conditional import for IO operations +import 'src/io_stub.dart' if (dart.library.io) 'src/io_impl.dart'; + export 'package:serious_python_platform_interface/src/utils.dart'; /// Provides cross-platform functionality for running Python programs. @@ -15,56 +17,80 @@ class SeriousPython { } /// Runs Python program from an asset. - /// - /// [assetPath] is the path to an asset which is a zip archive - /// with a Python program. When the app starts the archive is unpacked - /// to a temporary directory and Serious Python plugin will try to run - /// `main.py` in the root of the archive. Current directory is changed to - /// a temporary directory. - /// - /// If a Python app has a different entry point - /// it could be specified with [appFileName] parameter. - /// - /// Environment variables that must be available to a Python program could - /// be passed in [environmentVariables]. - /// - /// By default, Serious Python expects Python dependencies installed into - /// `__pypackages__` directory in the root of app directory. Additional paths - /// to look for 3rd-party packages can be specified with [modulePaths] parameter. - /// - /// Set [sync] to `true` to sychronously run Python program; otherwise the - /// program starts in a new thread. static Future run(String assetPath, {String? appFileName, - List? modulePaths, - Map? environmentVariables, - bool? sync}) async { - // unpack app from asset + List? modulePaths, + Map? environmentVariables, + bool? sync}) async { + // Handle web platform differently + if (kIsWeb) { + return _runWeb(assetPath, + appFileName: appFileName, + modulePaths: modulePaths, + environmentVariables: environmentVariables, + sync: sync); + } else { + return _runDesktop(assetPath, + appFileName: appFileName, + modulePaths: modulePaths, + environmentVariables: environmentVariables, + sync: sync); + } + } + + /// Web-specific implementation + static Future _runWeb(String assetPath, + {String? appFileName, + List? modulePaths, + Map? environmentVariables, + bool? sync}) async { + + String virtualPath; + if (path.extension(assetPath) == ".zip") { + virtualPath = assetPath.replaceAll(".zip", ""); + // TODO Check if path exists and except with unzip hint if not + } else { + virtualPath = assetPath; + } + + if (appFileName != null) { + virtualPath = '$virtualPath/$appFileName'; + } else { + virtualPath = '$virtualPath/main.py'; + } + + return runProgram(virtualPath, + modulePaths: modulePaths, + environmentVariables: environmentVariables, + sync: sync); + } + + /// Desktop-specific implementation + static Future _runDesktop(String assetPath, + {String? appFileName, + List? modulePaths, + Map? environmentVariables, + bool? sync}) async { String appPath = ""; if (path.extension(assetPath) == ".zip") { appPath = await extractAssetZip(assetPath); if (appFileName != null) { appPath = path.join(appPath, appFileName); - } else if (await File(path.join(appPath, "main.pyc")).exists()) { - appPath = path.join(appPath, "main.pyc"); - } else if (await File(path.join(appPath, "main.py")).exists()) { - appPath = path.join(appPath, "main.py"); } else { - throw Exception( - "App archive must contain either `main.py` or `main.pyc`; otherwise `appFileName` must be specified."); + appPath = await FileSystem.findMainFile(appPath); } } else { appPath = await extractAsset(assetPath); } // set current directory to app path - Directory.current = path.dirname(appPath); + await FileSystem.setCurrentDirectory(path.dirname(appPath)); // run python program return runProgram(appPath, modulePaths: modulePaths, environmentVariables: environmentVariables, - script: Platform.isWindows ? "" : null, + script: FileSystem.isWindows ? "" : null, sync: sync); } @@ -82,14 +108,13 @@ class SeriousPython { /// `__pypackages__` directory in the root of app directory. Additional paths /// to look for 3rd-party packages can be specified with [modulePaths] parameter. /// - /// Set [sync] to `true` to sychronously run Python program; otherwise the + /// Set [sync] to `true` to synchronously run Python program; otherwise the /// program starts in a new thread. static Future runProgram(String appPath, {String? script, - List? modulePaths, - Map? environmentVariables, - bool? sync}) async { - // run python program + List? modulePaths, + Map? environmentVariables, + bool? sync}) async { return SeriousPythonPlatform.instance.run(appPath, script: script, modulePaths: modulePaths, @@ -100,4 +125,4 @@ class SeriousPython { static void terminate() { SeriousPythonPlatform.instance.terminate(); } -} +} \ No newline at end of file diff --git a/src/serious_python/lib/src/io_impl.dart b/src/serious_python/lib/src/io_impl.dart new file mode 100644 index 0000000..3f51d4e --- /dev/null +++ b/src/serious_python/lib/src/io_impl.dart @@ -0,0 +1,20 @@ +import 'dart:io'; +import 'package:path/path.dart' as path; + +class FileSystem { + static Future findMainFile(String appPath) async { + if (await File(path.join(appPath, "main.pyc")).exists()) { + return path.join(appPath, "main.pyc"); + } else if (await File(path.join(appPath, "main.py")).exists()) { + return path.join(appPath, "main.py"); + } + throw Exception( + "App archive must contain either `main.py` or `main.pyc`; otherwise `appFileName` must be specified."); + } + + static Future setCurrentDirectory(String path) async { + Directory.current = path; + } + + static bool get isWindows => Platform.isWindows; +} \ No newline at end of file diff --git a/src/serious_python/lib/src/io_stub.dart b/src/serious_python/lib/src/io_stub.dart new file mode 100644 index 0000000..881ecce --- /dev/null +++ b/src/serious_python/lib/src/io_stub.dart @@ -0,0 +1,12 @@ +// Stub implementation for web +class FileSystem { + static Future findMainFile(String appPath) async { + return '$appPath/main.py'; + } + + static Future setCurrentDirectory(String path) async { + // No-op for web + } + + static bool get isWindows => false; +} \ No newline at end of file diff --git a/src/serious_python/pubspec.yaml b/src/serious_python/pubspec.yaml index eb8fbf3..8d698dd 100644 --- a/src/serious_python/pubspec.yaml +++ b/src/serious_python/pubspec.yaml @@ -10,6 +10,7 @@ platforms: macos: windows: linux: + web: environment: sdk: ">=3.0.0 <4.0.0" @@ -28,6 +29,8 @@ flutter: default_package: serious_python_windows linux: default_package: serious_python_linux + web: + default_package: serious_python_web dependencies: flutter: @@ -42,6 +45,8 @@ dependencies: path: ../serious_python_windows serious_python_linux: path: ../serious_python_linux + serious_python_web: + path: ../serious_python_web path_provider: ^2.1.3 archive: ^3.6.1 diff --git a/src/serious_python_linux/README.md b/src/serious_python_linux/README.md index fd20ffa..b7909cd 100644 --- a/src/serious_python_linux/README.md +++ b/src/serious_python_linux/README.md @@ -1,6 +1,6 @@ -# serious_python_ios +# serious_python_linux -The Windows implementation of `serious_python`. +The Linux implementation of `serious_python`. ## Usage diff --git a/src/serious_python_platform_interface/lib/src/utils.dart b/src/serious_python_platform_interface/lib/src/utils.dart index e364c4e..9b31809 100644 --- a/src/serious_python_platform_interface/lib/src/utils.dart +++ b/src/serious_python_platform_interface/lib/src/utils.dart @@ -1,123 +1,27 @@ -import 'dart:io'; - -import 'package:archive/archive_io.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter/widgets.dart'; -import 'package:path/path.dart' as p; -import 'package:path_provider/path_provider.dart'; +import 'utils_web.dart' if (dart.library.io) 'utils_io.dart'; Future extractAssetOrFile(String path, - {bool isAsset = true, String? targetPath, bool checkHash = false}) async { - WidgetsFlutterBinding.ensureInitialized(); - final supportDir = await getApplicationSupportDirectory(); - final destDir = - Directory(p.join(supportDir.path, "flet", targetPath ?? p.dirname(path))); - - String assetHash = ""; - String destHash = ""; - var hashFile = File(p.join(destDir.path, ".hash")); - - // re-create dir - if (await destDir.exists()) { - if (kDebugMode) { - // always re-create in debug mode - await destDir.delete(recursive: true); - } else { - if (checkHash) { - // read asset hash from asset - try { - assetHash = (await rootBundle.loadString("$path.hash")).trim(); - // ignore: empty_catches - } catch (e) {} - if (await hashFile.exists()) { - destHash = (await hashFile.readAsString()).trim(); - } - } - - if (assetHash != destHash || - (checkHash && assetHash == "" && destHash == "")) { - await destDir.delete(recursive: true); - } else { - debugPrint("Application archive already unpacked to ${destDir.path}"); - return destDir.path; - } - } - } - - debugPrint("extractAssetOrFile directory: ${destDir.path}"); - await destDir.create(recursive: true); - - // unpack from asset or file - debugPrint("Start unpacking archive: $path"); - Stopwatch stopwatch = Stopwatch()..start(); - - try { - Archive archive; - if (isAsset) { - final bytes = await rootBundle.load(path); - var data = bytes.buffer.asUint8List(); - archive = ZipDecoder().decodeBytes(data); - } else { - final inputStream = InputFileStream(path); - archive = ZipDecoder().decodeBuffer(inputStream); - } - await extractArchiveToDiskAsync(archive, destDir.path, asyncWrite: true); - } catch (e) { - debugPrint("Error unpacking archive: $e"); - await destDir.delete(recursive: true); - rethrow; - } - - debugPrint("Finished unpacking application archive in ${stopwatch.elapsed}"); - - if (checkHash) { - await hashFile.writeAsString(assetHash); - } - - return destDir.path; + {bool isAsset = true, String? targetPath, bool checkHash = false}) { + return getPlatformUtils().extractAssetOrFile(path, + isAsset: isAsset, targetPath: targetPath, checkHash: checkHash); } Future extractAssetZip(String assetPath, - {String? targetPath, bool checkHash = false}) async { - return extractAssetOrFile(assetPath, + {String? targetPath, bool checkHash = false}) { + return getPlatformUtils().extractAssetZip(assetPath, targetPath: targetPath, checkHash: checkHash); } Future extractFileZip(String filePath, - {String? targetPath, bool checkHash = false}) async { - return extractAssetOrFile(filePath, - isAsset: false, targetPath: targetPath, checkHash: checkHash); + {String? targetPath, bool checkHash = false}) { + return getPlatformUtils().extractFileZip(filePath, + targetPath: targetPath, checkHash: checkHash); } -Future extractAsset(String assetPath) async { - WidgetsFlutterBinding.ensureInitialized(); - - // (re-)create destination directory - final supportDir = await getApplicationSupportDirectory(); - final destDir = - Directory(p.join(supportDir.path, "flet", p.dirname(assetPath))); - - await destDir.create(recursive: true); - - // extract file from assets - var destPath = p.join(destDir.path, p.basename(assetPath)); - if (kDebugMode && await File(destPath).exists()) { - await File(destPath).delete(); - } - ByteData data = await rootBundle.load(assetPath); - List bytes = - data.buffer.asUint8List(data.offsetInBytes, data.lengthInBytes); - await File(destPath).writeAsBytes(bytes); - return destPath; +Future extractAsset(String assetPath) { + return getPlatformUtils().extractAsset(assetPath); } -Future getDirFiles(String path, {bool recursive = false}) async { - final dir = Directory(path); - if (!await dir.exists()) { - return ""; - } - return (await dir.list(recursive: recursive).toList()) - .map((file) => file.path) - .join('\n'); -} +Future getDirFiles(String path, {bool recursive = false}) { + return getPlatformUtils().getDirFiles(path, recursive: recursive); +} \ No newline at end of file diff --git a/src/serious_python_platform_interface/lib/src/utils_interface.dart b/src/serious_python_platform_interface/lib/src/utils_interface.dart new file mode 100644 index 0000000..5f6de01 --- /dev/null +++ b/src/serious_python_platform_interface/lib/src/utils_interface.dart @@ -0,0 +1,14 @@ +abstract class PlatformUtils { + Future extractAssetOrFile(String path, + {bool isAsset = true, String? targetPath, bool checkHash = false}); + + Future extractAssetZip(String assetPath, + {String? targetPath, bool checkHash = false}); + + Future extractFileZip(String filePath, + {String? targetPath, bool checkHash = false}); + + Future extractAsset(String assetPath); + + Future getDirFiles(String path, {bool recursive = false}); +} \ No newline at end of file diff --git a/src/serious_python_platform_interface/lib/src/utils_io.dart b/src/serious_python_platform_interface/lib/src/utils_io.dart new file mode 100644 index 0000000..b08d61f --- /dev/null +++ b/src/serious_python_platform_interface/lib/src/utils_io.dart @@ -0,0 +1,114 @@ +import 'dart:io'; + +import 'package:archive/archive_io.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; + +import 'utils_interface.dart'; + +class IOUtils implements PlatformUtils { + @override + Future extractAssetOrFile(String path, + {bool isAsset = true, String? targetPath, bool checkHash = false}) async { + WidgetsFlutterBinding.ensureInitialized(); + final supportDir = await getApplicationSupportDirectory(); + final destDir = Directory(p.join(supportDir.path, "flet", targetPath ?? p.dirname(path))); + + String assetHash = ""; + String destHash = ""; + var hashFile = File(p.join(destDir.path, ".hash")); + + if (await destDir.exists()) { + if (kDebugMode) { + await destDir.delete(recursive: true); + } else if (checkHash) { + try { + assetHash = (await rootBundle.loadString("$path.hash")).trim(); + } catch (e) { + assetHash = ""; + } + + if (await hashFile.exists()) { + destHash = (await hashFile.readAsString()).trim(); + } + + if (assetHash != destHash || (checkHash && assetHash.isEmpty && destHash.isEmpty)) { + await destDir.delete(recursive: true); + } else { + return destDir.path; + } + } + } + + await _extractArchive(path, destDir, isAsset); + + if (checkHash) { + await hashFile.writeAsString(assetHash); + } + + return destDir.path; + } + + Future _extractArchive(String path, Directory destDir, bool isAsset) async { + await destDir.create(recursive: true); + + try { + Archive archive; + if (isAsset) { + final bytes = await rootBundle.load(path); + var data = bytes.buffer.asUint8List(); + archive = ZipDecoder().decodeBytes(data); + } else { + final inputStream = InputFileStream(path); + archive = ZipDecoder().decodeBuffer(inputStream); + } + await extractArchiveToDiskAsync(archive, destDir.path, asyncWrite: true); + } catch (e) { + debugPrint("Error unpacking archive: $e"); + await destDir.delete(recursive: true); + rethrow; + } + } + + @override + Future extractAssetZip(String assetPath, {String? targetPath, bool checkHash = false}) { + return extractAssetOrFile(assetPath, targetPath: targetPath, checkHash: checkHash); + } + + @override + Future extractFileZip(String filePath, {String? targetPath, bool checkHash = false}) { + return extractAssetOrFile(filePath, isAsset: false, targetPath: targetPath, checkHash: checkHash); + } + + @override + Future extractAsset(String assetPath) async { + WidgetsFlutterBinding.ensureInitialized(); + final supportDir = await getApplicationSupportDirectory(); + final destDir = Directory(p.join(supportDir.path, "flet", p.dirname(assetPath))); + await destDir.create(recursive: true); + + var destPath = p.join(destDir.path, p.basename(assetPath)); + if (kDebugMode && await File(destPath).exists()) { + await File(destPath).delete(); + } + + ByteData data = await rootBundle.load(assetPath); + List bytes = data.buffer.asUint8List(data.offsetInBytes, data.lengthInBytes); + await File(destPath).writeAsBytes(bytes); + return destPath; + } + + @override + Future getDirFiles(String path, {bool recursive = false}) async { + final dir = Directory(path); + if (!await dir.exists()) { + return ""; + } + return (await dir.list(recursive: recursive).toList()).map((file) => file.path).join('\n'); + } +} + +PlatformUtils getPlatformUtils() => IOUtils(); diff --git a/src/serious_python_platform_interface/lib/src/utils_web.dart b/src/serious_python_platform_interface/lib/src/utils_web.dart new file mode 100644 index 0000000..4fd9e74 --- /dev/null +++ b/src/serious_python_platform_interface/lib/src/utils_web.dart @@ -0,0 +1,48 @@ +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'utils_interface.dart'; + +class WebUtils implements PlatformUtils { + @override + Future extractAssetOrFile(String path, + {bool isAsset = true, String? targetPath, bool checkHash = false}) async { + WidgetsFlutterBinding.ensureInitialized(); + try { + if (isAsset) { + await rootBundle.load(path); + } + return path; + } catch (e) { + debugPrint('Error handling web asset: $e'); + rethrow; + } + } + + @override + Future extractAssetZip(String assetPath, + {String? targetPath, bool checkHash = false}) { + return extractAssetOrFile(assetPath, + targetPath: targetPath, checkHash: checkHash); + } + + @override + Future extractFileZip(String filePath, + {String? targetPath, bool checkHash = false}) { + return extractAssetOrFile(filePath, + isAsset: false, targetPath: targetPath, checkHash: checkHash); + } + + @override + Future extractAsset(String assetPath) async { + WidgetsFlutterBinding.ensureInitialized(); + await rootBundle.load(assetPath); + return assetPath; + } + + @override + Future getDirFiles(String path, {bool recursive = false}) async { + return path; + } +} + +PlatformUtils getPlatformUtils() => WebUtils(); \ No newline at end of file diff --git a/src/serious_python_web/lib/pyodide.dart b/src/serious_python_web/lib/pyodide.dart new file mode 100644 index 0000000..492834b --- /dev/null +++ b/src/serious_python_web/lib/pyodide.dart @@ -0,0 +1,27 @@ +import 'package:js/js.dart'; + +// Define the external JavaScript functions we need +@JS('loadPyodide') +external Object loadPyodide(Object config); + +@JS() +@anonymous +class PyodideInterface { + external Object runPythonAsync(String code); + external Object runPython(String code); + external Object pyimport(String packageName); + external FileSystem get FS; + external PyodideGlobals get globals; +} + +@JS() +@anonymous +class PyodideGlobals { + external Object get(String name); +} + +@JS() +@anonymous +class FileSystem { + external Object writeFile(String path, String data, Object options); +} \ No newline at end of file diff --git a/src/serious_python_web/lib/serious_python_web.dart b/src/serious_python_web/lib/serious_python_web.dart new file mode 100644 index 0000000..123e1ce --- /dev/null +++ b/src/serious_python_web/lib/serious_python_web.dart @@ -0,0 +1,293 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:html' as html; +import 'dart:js_util' as js_util; + +import 'package:flutter/services.dart'; +import 'package:flutter_web_plugins/flutter_web_plugins.dart'; +import 'package:serious_python_platform_interface/serious_python_platform_interface.dart'; +import 'package:serious_python_web/pyodide.dart'; + +class SeriousPythonWeb extends SeriousPythonPlatform { + bool _isInitialized = false; + PyodideInterface? _pyodide; + final Set _loadedModules = {}; + + final String pyodideVersion = 'v0.27.2'; + late final String pyodideBaseURL = 'https://cdn.jsdelivr.net/pyodide/$pyodideVersion/full/'; + late final String pyodideJS = '${pyodideBaseURL}pyodide.js'; + + /// Registers this class as the default instance of [SeriousPythonPlatform] + static void registerWith(Registrar registrar) { + SeriousPythonPlatform.instance = SeriousPythonWeb(); + } + + @override + Future getPlatformVersion() async { + return 'web'; + } + + Future> _parseRequirementsFile(String requirementsFile) async { + try { + final content = await rootBundle.loadString(requirementsFile); + return content + .split('\n') + .map((line) => line.trim()) + .where((line) => + line.isNotEmpty && + !line.startsWith('#') && + !line.startsWith('-')) + .map((line) => line.split('==')[0].split('>=')[0].trim()) + .toList(); + } catch (e) { + print('Error parsing requirements.txt: $e'); + rethrow; + } + } + + Future _getRequirementsFileFromAssets() async { + // Load the asset manifest + // TODO Optimize not to load twice + final manifestContent = await rootBundle.loadString('AssetManifest.json'); + final Map manifest = json.decode(manifestContent); + + // Filter for Python files in the specified directory + return manifest.keys.firstWhere((String key) => key.contains("requirements.txt")); + } + + Future _loadPyodidePackages() async { + try { + // Parse requirements.txt + final requirementsFile = await _getRequirementsFileFromAssets(); + final packages = await _parseRequirementsFile(requirementsFile); + + if (packages.isEmpty) { + print("No packages found in requirements.txt"); + return; + } + + print("Loading Pyodide packages: ${packages.join(', ')}"); + + for(final package in packages) { + // Load packages + try { + await js_util.promiseToFuture( + js_util.callMethod(_pyodide!, 'loadPackage', [package]) + ); + } catch(e) { + print('Could not import package: $package'); + } + } + + print("Packages loaded successfully"); + } catch (e) { + print('Error loading packages: $e'); + rethrow; + } + } + + Future _initializePyodide() async { + if (_pyodide != null) return; + + try { + // Inject required meta tags first + _injectMetaTags(); + + // Create and add the script element + final scriptElement = html.ScriptElement() + ..src = pyodideJS + ..type = 'text/javascript'; + + html.document.head!.append(scriptElement); + + // Wait for script to load + await _waitForPyodide(); + + // Initialize Pyodide with correct base URL + final config = js_util.jsify({ + 'indexURL': pyodideBaseURL, + 'stdout': (String s) => print('Python stdout: $s'), + 'stderr': (String s) => print('Python stderr: $s') + }); + + final pyodidePromise = loadPyodide(config); + _pyodide = await js_util.promiseToFuture(pyodidePromise); + + // Test Python initialization + await _runPythonCode(""" +import sys +print(f"Python version: {sys.version}") + """); + + print("Pyodide initialized successfully"); + } catch (e, stackTrace) { + print('Error initializing Pyodide: $e'); + print('Stack trace: $stackTrace'); + rethrow; + } + } + + Future _waitForPyodide() async { + var attempts = 0; + while (attempts < 100) { + if (js_util.hasProperty(js_util.globalThis, 'loadPyodide')) { + return; + } + await Future.delayed(const Duration(milliseconds: 100)); + attempts++; + } + throw Exception('Timeout waiting for Pyodide to load'); + } + + static void _injectMetaTags() { + try { + final head = html.document.head; + + // Check if meta tags already exist + if (!head!.querySelectorAll('meta[name="cross-origin-opener-policy"]').isNotEmpty) { + final coopMeta = html.MetaElement() + ..name = 'cross-origin-opener-policy' + ..content = 'same-origin'; + head.append(coopMeta); + } + + if (!head.querySelectorAll('meta[name="cross-origin-embedder-policy"]').isNotEmpty) { + final coepMeta = html.MetaElement() + ..name = 'cross-origin-embedder-policy' + ..content = 'require-corp'; + head.append(coepMeta); + } + } catch (e) { + print('Error injecting meta tags: $e'); + } + } + + Future> _listPythonFilesInDirectory(String directory) async { + // Load the asset manifest + final manifestContent = await rootBundle.loadString('AssetManifest.json'); + final Map manifest = json.decode(manifestContent); + + // Filter for Python files in the specified directory + return manifest.keys.where((String key) => key.contains(directory) && key.endsWith('.py')).toList(); + } + + Future _loadModules(String moduleName, List modulePaths) async { + // Create a package directory in Pyodide's virtual filesystem + await _runPythonCode(''' +import os +import sys + +if not os.path.exists('/package'): + os.makedirs('/package') + +if not os.path.exists('/package/$moduleName'): + os.makedirs('/package/$moduleName') + +# Create __init__.py to make it a package +with open(f'/package/$moduleName/__init__.py', 'w') as f: + f.write('') + +if '/package' not in sys.path: + sys.path.append('/package') +'''); + + for (final modulePath in modulePaths) { + final moduleCode = await rootBundle.loadString(modulePath); + final fileName = modulePath.split('/').last; + + // Use Pyodide's filesystem API to write module Code + await _pyodide!.FS.writeFile('/package/$moduleName/$fileName', moduleCode, {'encoding': 'utf8'}); + } + } + + Future _loadModuleDirectories(List modulePaths) async { + final List moduleNamesToImport = []; + for(final directory in modulePaths) { + final moduleName = directory.split("/").last; + if(_loadedModules.contains(moduleName)) { + continue; + } + + final pythonFiles = await _listPythonFilesInDirectory(directory); + await _loadModules(moduleName, pythonFiles); + _loadedModules.add(moduleName); + moduleNamesToImport.add(moduleName); + } + // Import the modules using pyimport + for(final moduleNameToImport in moduleNamesToImport) { + await _pyodide!.pyimport('$moduleNameToImport'); + } + } + + Future ensureInitialized(String appPath) async { + if (!_isInitialized) { + // TODO REQUIREMENTS FILE PATH: ARGUMENT? + await _initializePyodide(); + await _loadPyodidePackages(); + _isInitialized = true; + } + } + + @override + Future run(String appPath, + {String? script, List? modulePaths, Map? environmentVariables, bool? sync}) async { + try { + await ensureInitialized(appPath); + + // Load the Python code from the asset + final pythonCode = await rootBundle.loadString(appPath); + + // Set environment variables if provided + if (environmentVariables != null) { + print("Running python web command with environment variables: $environmentVariables"); + + await _runPythonCode(''' +import os +${environmentVariables.entries.map((e) => "os.environ['${e.key}'] = '${e.value}'").join('\n')} +'''); + } + + // Add module paths if provided + if (modulePaths != null) { + int oldNModules = _loadedModules.length; + await _loadModuleDirectories(modulePaths); + int newNModules = _loadedModules.length; + print("Loaded ${newNModules - oldNModules} new modules!"); + } + +// final String debugCode = ''' +//import os +//import sys +// +//print("Python version:", sys.version) +//print("Python path:", sys.path) +//print("Current working directory:", os.getcwd()) +//print("Directory contents:", os.listdir('/package')) +//'''; + + await _runPythonCode(pythonCode); + + final result = _pyodide!.globals.get("pyodide_result"); + return result.toString(); + } catch (e) { + print('Error running Python code: $e'); + return 'Error: $e'; + } + } + + Future _runPythonCode(String code) async { + try { + // print("Running Python code: \n$code"); + final promise = _pyodide!.runPythonAsync(code); + await js_util.promiseToFuture(promise); + } catch (e) { + print('Error running Python code: $e'); + rethrow; + } + } + + @override + void terminate() { + // No need to implement for web + } +} diff --git a/src/serious_python_web/pubspec.yaml b/src/serious_python_web/pubspec.yaml new file mode 100644 index 0000000..2e3abfa --- /dev/null +++ b/src/serious_python_web/pubspec.yaml @@ -0,0 +1,31 @@ +name: serious_python_web +description: Web implementations of the serious_python plugin +homepage: https://flet.dev +repository: https://github.com/flet-dev/serious-python +version: 0.8.7 + +environment: + sdk: '>=3.1.3 <4.0.0' + flutter: '>=3.3.0' + +dependencies: + flutter: + sdk: flutter + flutter_web_plugins: + sdk: flutter + js: ^0.6.7 + serious_python_platform_interface: + path: ../serious_python_platform_interface + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^2.0.0 + +flutter: + plugin: + implements: serious_python + platforms: + web: + pluginClass: SeriousPythonWeb + fileName: serious_python_web.dart \ No newline at end of file diff --git a/src/serious_python_windows/README.md b/src/serious_python_windows/README.md index fd20ffa..b61b080 100644 --- a/src/serious_python_windows/README.md +++ b/src/serious_python_windows/README.md @@ -1,4 +1,4 @@ -# serious_python_ios +# serious_python_windows The Windows implementation of `serious_python`.