convert mpy to saga

- unit tests depend on bug fix in @pybricks/mpy-cross-v4
- removes last use of redux-thunk
This commit is contained in:
David Lechner
2020-05-27 20:57:22 -05:00
committed by David Lechner
parent 0d30e113da
commit 297faafc27
10 changed files with 183 additions and 51 deletions
+2
View File
@@ -9,6 +9,7 @@ import {
} from './bootloader';
import { EditorAction } from './editor';
import { HubAction, HubMessageAction } from './hub';
import { MpyAction } from './mpy';
import { NotificationAction } from './notification';
import { ServiceWorkerAction } from './service-worker';
import { TerminalDataAction } from './terminal';
@@ -28,6 +29,7 @@ export type Action =
| EditorAction
| HubMessageAction
| HubAction
| MpyAction
| NotificationAction
| ServiceWorkerAction
| TerminalDataAction;
+35 -18
View File
@@ -1,30 +1,47 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2020 The Pybricks Authors
import { compile as mpyCrossCompile } from '@pybricks/mpy-cross-v4';
import { Action } from 'redux';
import { ThunkAction } from 'redux-thunk';
export enum MpyActionType {
Compiled = 'mpy.action.compile',
Compile = 'mpy.action.compile',
DidCompile = 'mpy.action.didCompile',
DidFailToCompile = 'mpy.action.didFailToCompile',
}
export interface MpyCompiledAction extends Action<MpyActionType.Compiled> {
/**
* The compiled .mpy data.
*/
data?: Uint8Array;
/**
* Error output.
*/
err?: string;
/** Action that requests that a script is compiled. */
export interface MpyCompileAction extends Action<MpyActionType.Compile> {
/** The script to compile. */
readonly script: string;
/** The compiler command line options */
options?: string[];
}
type MpyCompileAction = ThunkAction<Promise<MpyCompiledAction>, {}, {}, Action>;
export function compile(script: string, options?: string[]): MpyCompileAction {
return async function (): Promise<MpyCompiledAction> {
const result = await mpyCrossCompile('main.py', script, options);
return { type: MpyActionType.Compiled, data: result.mpy, err: result.err };
};
return { type: MpyActionType.Compile, script, options };
}
export interface MpyDidCompileAction extends Action<MpyActionType.DidCompile> {
/** The compiled .mpy file. */
readonly data: Uint8Array;
}
export function didCompile(data: Uint8Array): MpyDidCompileAction {
return { type: MpyActionType.DidCompile, data };
}
export interface MpyDidFailToCompileAction
extends Action<MpyActionType.DidFailToCompile> {
/** Error output. */
readonly err: string;
}
export function didFailToCompile(err: string): MpyDidFailToCompileAction {
return { type: MpyActionType.DidFailToCompile, err };
}
/** Common type for all mpy actions. */
export type MpyAction =
| MpyCompileAction
| MpyDidCompileAction
| MpyDidFailToCompileAction;
-2
View File
@@ -9,7 +9,6 @@ import { applyMiddleware, createStore } from 'redux';
import { createLogger } from 'redux-logger';
import { createEpicMiddleware } from 'redux-observable';
import createSagaMiddleware from 'redux-saga';
import thunkMiddleware from 'redux-thunk';
import './index.scss';
import { success, update } from './actions/service-worker';
import App from './components/App';
@@ -33,7 +32,6 @@ const i18n = new I18nManager({
const store = createStore(
rootReducer,
applyMiddleware(
thunkMiddleware,
sagaMiddleware,
epicMiddleware,
serviceMiddleware,
+13 -7
View File
@@ -12,7 +12,6 @@ import {
delay,
fork,
put,
putResolve,
race,
take,
takeEvery,
@@ -59,7 +58,12 @@ import {
send,
stateResponse,
} from '../actions/bootloader';
import { MpyCompiledAction, compile } from '../actions/mpy';
import {
MpyActionType,
MpyDidCompileAction,
MpyDidFailToCompileAction,
compile,
} from '../actions/mpy';
import * as notification from '../actions/notification';
import {
Command,
@@ -262,12 +266,14 @@ function* loadFirmware(
);
}
const mpy = (yield putResolve(
(compile(main, metadata['mpy-cross-options']) as unknown) as Action,
)) as MpyCompiledAction;
yield put(compile(main, metadata['mpy-cross-options']));
const [mpy, mpyFail] = (yield race([
take(MpyActionType.DidCompile),
take(MpyActionType.DidFailToCompile),
])) as [MpyDidCompileAction, MpyDidFailToCompileAction];
if (!mpy.data) {
throw Error(mpy.err);
if (mpyFail) {
throw Error(mpyFail.err);
}
// compute offset for checksum - must be aligned to 4-byte boundary
+2 -1
View File
@@ -3,8 +3,9 @@
import { all } from 'redux-saga/effects';
import bootloader from './bootloader';
import mpy from './mpy';
/* istanbul ignore next */
export default function* (): Generator {
yield all([bootloader()]);
yield all([bootloader(), mpy()]);
}
+67
View File
@@ -0,0 +1,67 @@
import { runSaga, stdChannel } from 'redux-saga';
import { Action } from '../actions';
import {
MpyActionType,
MpyDidCompileAction,
MpyDidFailToCompileAction,
compile,
} from '../actions/mpy';
import mpy from './mpy';
enum MpyFeatureFlags {
MICROPY_OPT_CACHE_MAP_LOOKUP_IN_BYTECODE = 1 << 0,
MICROPY_PY_BUILTINS_STR_UNICODE = 1 << 1,
}
test('compiler works', async () => {
const channel = stdChannel();
const dispatched = new Array<Action>();
const task = runSaga(
{
channel,
dispatch: (action: Action) => dispatched.push(action),
},
mpy,
);
channel.put(compile('print("hello!")'));
// TODO: not sure what the best way to handle this is. We could just wait
// for one dispatch, but then we could miss a bug where there is more than
// one dispatch. And if we make the time too short, we could get intermittent
// failures.
setTimeout(() => task.cancel(), 1000);
await task.toPromise();
expect(dispatched.length).toBe(1);
expect(dispatched[0].type).toBe(MpyActionType.DidCompile);
const { data } = dispatched[0] as MpyDidCompileAction;
expect(data[0]).toBe('M'.charCodeAt(0));
expect(data[1]).toBe(4); // ABI version
expect(data[2]).toBe(MpyFeatureFlags.MICROPY_PY_BUILTINS_STR_UNICODE);
expect(data[3]).toBe(31); // small int bits
});
test('compiler error works', async () => {
const channel = stdChannel();
const dispatched = new Array<Action>();
const task = runSaga(
{
channel,
dispatch: (action: Action) => dispatched.push(action),
},
mpy,
);
channel.put(compile('syntax error!'));
// TODO: not sure what the best way to handle this is. We could just wait
// for one dispatch, but then we could miss a bug where there is more than
// one dispatch. And if we make the time too short, we could get intermittent
// failures.
setTimeout(() => task.cancel(), 1000);
await task.toPromise();
expect(dispatched.length).toBe(1);
expect(dispatched[0].type).toBe(MpyActionType.DidFailToCompile);
const { err } = dispatched[0] as MpyDidFailToCompileAction;
expect(err).toContain('SyntaxError');
});
+31
View File
@@ -0,0 +1,31 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2020 The Pybricks Authors
import { CompileResult, compile as mpyCrossCompile } from '@pybricks/mpy-cross-v4';
import { call, put, takeEvery } from 'redux-saga/effects';
import {
MpyActionType,
MpyCompileAction,
didCompile,
didFailToCompile,
} from '../actions/mpy';
/**
* Compiles a script to .mpy and dispatches either didCompile on success or
* didFailToCompile on error.
* @param action A mpy compile action.
*/
function* compile(action: MpyCompileAction): Generator {
const result = (yield call(() =>
mpyCrossCompile('main.py', action.script, action.options),
)) as CompileResult;
if (result.status === 0 && result.mpy) {
yield put(didCompile(result.mpy));
} else {
yield put(didFailToCompile(result.err));
}
}
export default function* (): Generator {
yield takeEvery(MpyActionType.Compile, compile);
}
+28 -12
View File
@@ -1,22 +1,35 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2020 The Pybricks Authors
import { AnyAction } from 'redux';
import { ThunkDispatch } from 'redux-thunk';
import { Action } from '../actions';
import { EventEmitter } from 'events';
import { Action, Dispatch } from '../actions';
import { write } from '../actions/ble';
import { HubActionType, HubRuntimeStatusType, updateStatus } from '../actions/hub';
import { compile } from '../actions/mpy';
import {
MpyActionType,
MpyDidCompileAction,
MpyDidFailToCompileAction,
compile,
} from '../actions/mpy';
import { getChecksum } from '../epics/hub';
import { RootState } from '../reducers';
import { combineServices } from '.';
// TODO: this file needs to be converted to a saga
type Dispatch = ThunkDispatch<{}, {}, AnyAction>;
const downloadChunkSize = 100;
const compiler = new EventEmitter();
function didCompile(action: Action): void {
if (action.type === MpyActionType.DidCompile) {
compiler.emit('didCompile', action);
}
if (action.type === MpyActionType.DidFailToCompile) {
compiler.emit('didFailToCompile', action);
}
}
async function downloadAndRun(
action: Action,
dispatch: Dispatch,
@@ -32,11 +45,14 @@ async function downloadAndRun(
console.log('no current editor');
return;
}
const mpy = await dispatch(compile(script, ['-mno-unicode']));
if (mpy.data === undefined) {
console.log(`failed to compile: ${mpy.err}`);
return;
}
dispatch(compile(script, ['-mno-unicode']));
const mpy = await new Promise<MpyDidCompileAction>((resolve, reject): void => {
compiler.on('didCompile', (a: MpyDidCompileAction): void => resolve(a));
compiler.on('didFailToCompile', (a: MpyDidFailToCompileAction) =>
reject(new Error(a.err)),
);
});
// let everyone know the runtime is busy loading the program
dispatch(updateStatus(HubRuntimeStatusType.Loading));
@@ -87,4 +103,4 @@ function stop(action: Action, dispatch: Dispatch): void {
dispatch(write(stopCommand));
}
export default combineServices(downloadAndRun, startRepl, stop);
export default combineServices(didCompile, downloadAndRun, startRepl, stop);