diff --git a/frontend/editor/extended_python_generator.ts b/frontend/editor/extended_python_generator.ts index 2130929a..8759dd9d 100644 --- a/frontend/editor/extended_python_generator.ts +++ b/frontend/editor/extended_python_generator.ts @@ -130,6 +130,9 @@ export class OpModeDetails { // Extends the python generator to collect some information about functions and // variables that have been defined so they can be used in other modules. +/** The resource name that the robot reports usage with, using wpiutil.report_usage. */ +const USAGE_REPORT_RESOURCE = 'Blocks'; + export class ExtendedPythonGenerator extends PythonGenerator { private readonly context: GeneratorContext; private workspace: Blockly.Workspace | null = null; @@ -154,6 +157,10 @@ export class ExtendedPythonGenerator extends PythonGenerator { // All opmodes in the project (used when generating robot code) private allOpModeDetails: OpModeDetails[] = []; + // The usage report (as JSON) that the robot's __init__ sends with wpiutil.report_usage, or null + // if the robot shouldn't report usage. + private usageReportJson: string | null = null; + private generateErrorHandling: boolean = false; constructor() { @@ -220,6 +227,11 @@ export class ExtendedPythonGenerator extends PythonGenerator { switch (this.getModuleType()) { case storageModule.ModuleType.ROBOT: + if (this.usageReportJson !== null) { + this.importModule('wpiutil'); + initStatements += this.INDENT + 'wpiutil.report_usage(' + + this.quote_(USAGE_REPORT_RESOURCE) + ', ' + this.quote_(this.usageReportJson) + ')\n'; + } initStatements += this.INDENT + 'self.mechanisms = []\n'; initStatements += this.INDENT + 'self.event_handlers = {}\n'; initStatements += this.INDENT + 'self.define_hardware()\n'; @@ -652,6 +664,11 @@ export class ExtendedPythonGenerator extends PythonGenerator { this.allOpModeDetails = details; } + /** Sets the usage report (as JSON) for the robot to report, or null to not report usage. */ + setUsageReportJson(usageReportJson: string | null) { + this.usageReportJson = usageReportJson; + } + getClassName(): string { return this.context.getClassName(); } diff --git a/frontend/libraries/library_registry.ts b/frontend/libraries/library_registry.ts index cd1b43d3..197fadb1 100644 --- a/frontend/libraries/library_registry.ts +++ b/frontend/libraries/library_registry.ts @@ -47,6 +47,11 @@ export function collectPythonData(libraries: Library[]): PythonData { return pythonData; } +/** Returns all installed libraries. */ +export function getInstalledLibraries(): Library[] { + return [...librariesByName.values()]; +} + /** Returns the installed library with the given name, or undefined if it isn't installed. */ export function getInstalledLibrary(name: string): Library | undefined { return librariesByName.get(name); diff --git a/frontend/storage/create_python_files.ts b/frontend/storage/create_python_files.ts index 2fd49cfd..6e30aac3 100644 --- a/frontend/storage/create_python_files.ts +++ b/frontend/storage/create_python_files.ts @@ -12,9 +12,10 @@ import { extendedPythonGenerator } from '../editor/extended_python_generator'; import { getOpModeDetailsFromBlocksJson } from '../blocks/mrc_opmode_details'; import { Storage } from './common_storage'; import { Module } from './module'; -import { parseModuleContentText } from './module_content'; +import { ModuleContent, parseModuleContentText } from './module_content'; import { Project } from './project'; import { pascalCaseToSnakeCase } from './names'; +import { createUsageReport } from './usage_report'; import JSZip from 'jszip'; /** Result of Python code generation for a single module */ @@ -35,17 +36,18 @@ interface ProjectPythonResult { /** * Generate Python code for a single module using headless Blockly - * @param module The module containing Blockly JSON - * @param storage The storage interface to fetch module content + * @param module The module + * @param moduleContent The content of the module, or an error if it couldn't be fetched * @returns Result containing generated Python code or error */ -async function generatePythonForModule(module: Module, storage: Storage): Promise { +function generatePythonForModule( + module: Module, moduleContent: ModuleContent | Error): ModulePythonResult { const moduleName = pascalCaseToSnakeCase(module.className); try { - // Fetch the module content from storage - const moduleContentText = await storage.fetchFileContentText(module.modulePath); - const moduleContent = parseModuleContentText(moduleContentText); + if (moduleContent instanceof Error) { + throw moduleContent; + } // Create a headless workspace const workspace = workspaces.createHeadlessWorkspace(module.moduleType); @@ -77,6 +79,26 @@ async function generatePythonForModule(module: Module, storage: Storage): Promis } } +/** + * Fetches the content of each module in the project. + * @returns Maps the moduleId of each module to its content, or to the error if it couldn't be + * fetched or parsed. + */ +async function fetchModuleContents( + project: Project, storage: Storage): Promise> { + const modules: Module[] = [project.robot, ...project.mechanisms, ...project.opModes]; + const contents = await Promise.all(modules.map(async (module): Promise => { + try { + return parseModuleContentText(await storage.fetchFileContentText(module.modulePath)); + } catch (error) { + return error instanceof Error ? error : new Error(String(error)); + } + })); + const moduleContents = new Map(); + modules.forEach((module, i) => moduleContents.set(module.moduleId, contents[i])); + return moduleContents; +} + /** * Generate Python code for all modules in a project using headless Blockly * @param project The project containing multiple modules @@ -87,42 +109,40 @@ async function generatePythonForProject(project: Project, storage: Storage): Pro const moduleResults: ModulePythonResult[] = []; let errorCount = 0; + const moduleContents = await fetchModuleContents(project, storage); + const validModuleContents = new Map(); + for (const [moduleId, moduleContent] of moduleContents) { + if (!(moduleContent instanceof Error)) { + validModuleContents.set(moduleId, moduleContent); + } + } + // Extract opmode details from all opmode modules and set on the generator // so the robot constructor can emit RegisterOpMode calls. - const allOpModeDetails = await Promise.all( - project.opModes.map(async (opMode) => { - const contentText = await storage.fetchFileContentText(opMode.modulePath); - const moduleContent = parseModuleContentText(contentText); - return getOpModeDetailsFromBlocksJson(moduleContent.getBlocks(), opMode.className); - }) - ); + const allOpModeDetails = project.opModes.map((opMode) => { + const moduleContent = moduleContents.get(opMode.moduleId); + if (!moduleContent || moduleContent instanceof Error) { + throw moduleContent || new Error(`Missing content for ${opMode.className}`); + } + return getOpModeDetailsFromBlocksJson(moduleContent.getBlocks(), opMode.className); + }); extendedPythonGenerator.setAllOpModeDetails( allOpModeDetails.filter((d): d is NonNullable => d !== null) ); - // Process the robot module - const robotResult = await generatePythonForModule(project.robot, storage); - moduleResults.push(robotResult); - if (!robotResult.success) { - errorCount++; - } - - // Process all mechanism modules - for (const mechanism of project.mechanisms) { - const result = await generatePythonForModule(mechanism, storage); - moduleResults.push(result); - if (!result.success) { - errorCount++; - } - } - - // Process all opmode modules - for (const opMode of project.opModes) { - const result = await generatePythonForModule(opMode, storage); - moduleResults.push(result); - if (!result.success) { - errorCount++; + // The robot code reports usage with wpiutil.report_usage. + extendedPythonGenerator.setUsageReportJson( + JSON.stringify(createUsageReport(project, validModuleContents))); + try { + for (const module of [project.robot, ...project.mechanisms, ...project.opModes]) { + const result = generatePythonForModule(module, moduleContents.get(module.moduleId)!); + moduleResults.push(result); + if (!result.success) { + errorCount++; + } } + } finally { + extendedPythonGenerator.setUsageReportJson(null); } return { diff --git a/frontend/storage/usage_report.ts b/frontend/storage/usage_report.ts new file mode 100644 index 00000000..caff1839 --- /dev/null +++ b/frontend/storage/usage_report.ts @@ -0,0 +1,110 @@ +// Copyright (c) FIRST and other WPILib contributors. + +/** + * @fileoverview Builds the usage report that deployed robot code sends with + * wpiutil.report_usage('Blocks', ...). + */ + +import { getBlocksVersion } from '../libraries/blocks_lib'; +import { getInstalledLibraries } from '../libraries/library_registry'; +import { ModuleContent, getTopLevelBlocksJson } from './module_content'; +import { Project } from './project'; + +export interface UsageReport { + /** The version of blocks that generated the code. */ + version: string; + /** Maps the name of each installed third party library to its version. */ + libraries: {[name: string]: string}; + /** + * Maps each block type used in the project to the number of times it is used. This also tells + * how many modules there are: the robot and each mechanism have one + * mrc_mechanism_component_holder block and each opmode has one mrc_opmode_details block. + */ + blocks: {[blockType: string]: number}; + /** + * Maps each component class (for example 'wpilib.ExpansionHubMotor') used by the robot to the + * number of those components. Components of a mechanism are counted once for each time the + * mechanism is added to the robot. + */ + components: {[className: string]: number}; +} + +/** + * Builds the usage report for the given project. + * @param project The project. + * @param moduleContents Maps the moduleId of each module in the project to its content. + */ +export function createUsageReport( + project: Project, moduleContents: Map): UsageReport { + const libraries: {[name: string]: string} = {}; + for (const library of getInstalledLibraries()) { + libraries[library.metadata.name] = library.metadata.version; + } + + const blocks: {[blockType: string]: number} = {}; + for (const moduleContent of moduleContents.values()) { + countBlocks(moduleContent.getBlocks(), blocks); + } + + const components: {[className: string]: number} = {}; + const robotContent = moduleContents.get(project.robot.moduleId); + if (robotContent) { + countComponents(robotContent, components); + for (const mechanismInRobot of robotContent.getMechanisms()) { + const mechanismContent = moduleContents.get(mechanismInRobot.moduleId); + if (mechanismContent) { + countComponents(mechanismContent, components); + } + } + } + + return { + version: getBlocksVersion(), + libraries, + blocks: sortKeys(blocks), + components: sortKeys(components), + }; +} + +/** + * Adds the number of times each block type is used in the given blocks to counts. Shadow blocks + * are not counted, since they are put there by the toolbox rather than by the user. + */ +function countBlocks(blocks: {[key: string]: any}, counts: {[blockType: string]: number}): void { + const walk = (blockJson: {[key: string]: any}): void => { + if (!blockJson || typeof blockJson !== 'object') { + return; + } + if (typeof blockJson.type === 'string') { + counts[blockJson.type] = (counts[blockJson.type] || 0) + 1; + } + if (blockJson.inputs) { + for (const inputName in blockJson.inputs) { + const input = blockJson.inputs[inputName]; + if (input && input.block) { + walk(input.block); + } + } + } + if (blockJson.next && blockJson.next.block) { + walk(blockJson.next.block); + } + }; + getTopLevelBlocksJson(blocks).forEach(walk); +} + +/** Adds the public and private components of the given module to counts. */ +function countComponents( + moduleContent: ModuleContent, counts: {[className: string]: number}): void { + for (const component of [...moduleContent.getComponents(), ...moduleContent.getPrivateComponents()]) { + counts[component.className] = (counts[component.className] || 0) + 1; + } +} + +function sortKeys(counts: {[key: string]: number}): {[key: string]: number} { + const sorted: {[key: string]: number} = {}; + for (const key of Object.keys(counts).sort()) { + sorted[key] = counts[key]; + } + return sorted; +} diff --git a/tests/usage_report.test.ts b/tests/usage_report.test.ts new file mode 100644 index 00000000..8359c4d4 --- /dev/null +++ b/tests/usage_report.test.ts @@ -0,0 +1,116 @@ +// Copyright (c) FIRST and other WPILib contributors. + +import { describe, expect, test } from 'vitest'; +import * as blocksLib from '../frontend/libraries/blocks_lib'; +import { setInstalledLibraries } from '../frontend/libraries/library_registry'; +import { Component, ModuleContent } from '../frontend/storage/module_content'; +import { Module, ModuleType } from '../frontend/storage/module'; +import { Project } from '../frontend/storage/project'; +import { createUsageReport } from '../frontend/storage/usage_report'; + +const LIBRARY: blocksLib.Library = { + metadata: { + formatVersion: 1, + name: 'demo', + version: '1.2.3', + author: 'Someone', + summary: '', + details: '', + blocksVersion: '>=0.0.1', + }, + toolboxes: {}, + wheels: [], + pythonModules: [], +}; + +function makeModule(moduleType: ModuleType, className: string): Module { + return { + modulePath: `projects/P/${className}.${moduleType}.json`, + moduleType, + moduleId: className + 'Id', + projectName: 'P', + className, + }; +} + +function component(className: string): Component { + return { componentId: className, name: className, className, args: [] }; +} + +function makeBlocks(...topLevelBlocks: any[]): any { + return { blocks: { languageVersion: 0, blocks: topLevelBlocks } }; +} + +describe('usage report', () => { + test('counts libraries, blocks, and components', () => { + const robot = makeModule(ModuleType.ROBOT, 'Robot'); + const arm = makeModule(ModuleType.MECHANISM, 'Arm'); + const unused = makeModule(ModuleType.MECHANISM, 'Unused'); + const teleop = makeModule(ModuleType.OPMODE, 'Teleop'); + const project: Project = { + projectName: 'P', + robot, + mechanisms: [arm, unused], + opModes: [teleop], + projectInfo: {} as any, + }; + + const robotContent = new ModuleContent( + ModuleType.ROBOT, robot.moduleId, + makeBlocks({ + type: 'mrc_class_method_def', + inputs: { + STACK: { + block: { + type: 'controls_if', + inputs: { + // The shadow isn't counted, but the block covering it is. + IF0: { shadow: { type: 'logic_boolean' }, block: { type: 'logic_compare' } }, + }, + next: { block: { type: 'controls_if' } }, + }, + }, + }, + }), + [ + { moduleId: arm.moduleId, mechanismId: 'm1', name: 'leftArm', className: 'arm.Arm' }, + { moduleId: arm.moduleId, mechanismId: 'm2', name: 'rightArm', className: 'arm.Arm' }, + ], + [component('wpilib.ExpansionHubMotor')], [], [], []); + const armContent = new ModuleContent( + ModuleType.MECHANISM, arm.moduleId, makeBlocks({ type: 'mrc_class_method_def' }), [], + [component('wpilib.ExpansionHubMotor')], [component('wpilib.DigitalInput')], [], []); + const unusedContent = new ModuleContent( + ModuleType.MECHANISM, unused.moduleId, makeBlocks(), [], + [component('wpilib.AnalogInput')], [], [], []); + const teleopContent = new ModuleContent( + ModuleType.OPMODE, teleop.moduleId, + makeBlocks({ type: 'mrc_class_method_def' }, { type: 'mrc_opmode_details' }), [], [], [], [], []); + + setInstalledLibraries([LIBRARY]); + try { + const report = createUsageReport(project, new Map([ + [robot.moduleId, robotContent], + [arm.moduleId, armContent], + [unused.moduleId, unusedContent], + [teleop.moduleId, teleopContent], + ])); + expect(report.version).toBe(blocksLib.getBlocksVersion()); + expect(report.libraries).toEqual({ demo: '1.2.3' }); + expect(report.blocks).toEqual({ + controls_if: 2, + logic_compare: 1, + mrc_class_method_def: 3, + mrc_opmode_details: 1, + }); + // The arm's components are counted once for each arm in the robot, and the unused + // mechanism's components aren't counted. + expect(report.components).toEqual({ + 'wpilib.DigitalInput': 2, + 'wpilib.ExpansionHubMotor': 3, + }); + } finally { + setInstalledLibraries([]); + } + }); +});