summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorhathach <[email protected]>2025-12-03 11:09:41 +0700
committerhathach <[email protected]>2025-12-03 23:04:49 +0700
commitf51ca33f25841147e93c72458c927261806cdc0e (patch)
tree7882c1ebe7d468512a3d7d770000474d7d01af0e
parentee3d3e3551f95757b85de1c2c9777a1daed8f78d (diff)
upload metrics.json and aggregate code metrics, post metrics comment
fine tune ci matrix run
-rw-r--r--.github/workflows/build.yml416
-rw-r--r--.github/workflows/build_util.yml2
-rwxr-xr-x.github/workflows/ci_set_matrix.py6
-rw-r--r--examples/CMakeLists.txt4
-rw-r--r--hw/bsp/family_support.cmake6
-rwxr-xr-xtools/build.py2
-rwxr-xr-xtools/get_deps.py2
-rw-r--r--tools/metrics.py254
8 files changed, 472 insertions, 220 deletions
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 5e996d9d9..b0b636c65 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -83,6 +83,8 @@ jobs:
code-metrics:
needs: cmake
runs-on: ubuntu-latest
+ permissions:
+ pull-requests: write
steps:
- name: Checkout TinyUSB
uses: actions/checkout@v4
@@ -96,197 +98,233 @@ jobs:
- name: Aggregate Code Metrics
run: |
- tree cmake-build
python tools/get_deps.py
- python tools/metrics.py -f tinyusb/src cmake-build/*/metrics.json
+ pip install tools/linkermap/
+ python tools/metrics.py combine -j -m -f tinyusb/src cmake-build/*/metrics.json
+
+ - name: Upload Metrics Artifact
+ if: github.event_name == 'push'
+ uses: actions/upload-artifact@v5
+ with:
+ name: metrics-tinyusb
+ path: metrics.json
+
+ - name: Download Base Branch Metrics
+ if: github.event_name == 'pull_request'
+ uses: dawidd6/action-download-artifact@v11
+ with:
+ workflow: build.yml
+ branch: ${{ github.base_ref }}
+ name: metrics-tinyusb
+ path: base-metrics
+ continue-on-error: true
+
+ - name: Compare with Base Branch
+ if: github.event_name == 'pull_request'
+ run: |
+ if [ -f base-metrics/metrics.json ]; then
+ python tools/metrics.py compare -f tinyusb/src base-metrics/metrics.json metrics.json
+ cat metrics_compare.md
+ else
+ echo "No base metrics found, skipping comparison"
+ cp metrics.md metrics_compare.md
+ fi
+
+ - name: Post Code Metrics as PR Comment
+ if: github.event_name == 'pull_request'
+ uses: marocchino/sticky-pull-request-comment@v2
+ with:
+ header: code-metrics
+ path: metrics_compare.md
+
# ---------------------------------------
# Build Make: only build on push with one-per-family
# ---------------------------------------
-# make:
-# if: github.event_name == 'push'
-# needs: set-matrix
-# uses: ./.github/workflows/build_util.yml
-# strategy:
-# fail-fast: false
-# matrix:
-# toolchain:
-# - 'aarch64-gcc'
-# #- 'arm-clang'
-# - 'arm-gcc'
-# - 'msp430-gcc'
-# - 'riscv-gcc'
-# - 'rx-gcc'
-# with:
-# build-system: 'make'
-# toolchain: ${{ matrix.toolchain }}
-# build-args: ${{ toJSON(fromJSON(needs.set-matrix.outputs.json)[matrix.toolchain]) }}
-# one-per-family: true
-#
-# # ---------------------------------------
-# # Build IAR
-# # Since IAR Token secret is not passed to forked PR, only build non-forked PR with make.
-# # cmake is built by circle-ci. Due to IAR limit capacity, only build oe per family
-# # ---------------------------------------
-# arm-iar:
-# if: false # disable for now since we got reach capacity limit too often
-# #if: github.event_name == 'push' && github.repository_owner == 'hathach'
-# needs: set-matrix
-# uses: ./.github/workflows/build_util.yml
-# secrets: inherit
-# strategy:
-# fail-fast: false
-# matrix:
-# build-system:
-# - 'make'
-# with:
-# build-system: ${{ matrix.build-system }}
-# toolchain: 'arm-iar'
-# build-args: ${{ toJSON(fromJSON(needs.set-matrix.outputs.json)['arm-iar']) }}
-# one-per-family: true
-#
-# # ---------------------------------------
-# # Build Make/CMake on Windows/MacOS
-# # ---------------------------------------
-# build-os:
-# if: github.event_name == 'pull_request'
-# uses: ./.github/workflows/build_util.yml
-# strategy:
-# fail-fast: false
-# matrix:
-# os: [windows-latest, macos-latest]
-# build-system: [ 'make', 'cmake' ]
-# with:
-# os: ${{ matrix.os }}
-# build-system: ${{ matrix.build-system }}
-# toolchain: 'arm-gcc-${{ matrix.os }}'
-# build-args: '["stm32h7"]'
-# one-per-family: true
-#
-# # ---------------------------------------
-# # Zephyr
-# # ---------------------------------------
-# zephyr:
-# if: github.event_name == 'push'
-# runs-on: ubuntu-latest
-# steps:
-# - name: Checkout TinyUSB
-# uses: actions/checkout@v4
-#
-# - name: Setup Zephyr project
-# uses: zephyrproject-rtos/action-zephyr-setup@v1
-# with:
-# app-path: examples
-# toolchains: arm-zephyr-eabi
-#
-# - name: Build
-# run: |
-# west build -b nrf52840dk -d examples/device/cdc_msc/build examples/device/cdc_msc -- -DRTOS=zephyr
-# west build -b nrf52840dk -d examples/device/msc_dual_lun/build examples/device/msc_dual_lun -- -DRTOS=zephyr
-#
-# # ---------------------------------------
-# # Hardware in the loop (HIL)
-# # Run on PR only (hil-tinyusb), hil-hfp only run on non-forked PR
-# # ---------------------------------------
-# hil-build:
-# if: |
-# github.repository_owner == 'hathach' &&
-# (github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch')
-# needs: set-matrix
-# uses: ./.github/workflows/build_util.yml
-# strategy:
-# fail-fast: false
-# matrix:
-# toolchain:
-# - 'arm-gcc'
-# - 'esp-idf'
-# with:
-# build-system: 'cmake'
-# toolchain: ${{ matrix.toolchain }}
-# build-args: ${{ toJSON(fromJSON(needs.set-matrix.outputs.hil_json)[matrix.toolchain]) }}
-# one-per-family: true
-# upload-artifacts: true
-#
-# # ---------------------------------------
-# # Hardware in the loop (HIL)
-# # self-hosted on local VM, for attached hardware checkout HIL_JSON
-# # ---------------------------------------
-# hil-tinyusb:
-# if: |
-# github.repository_owner == 'hathach' &&
-# (github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch')
-# needs: hil-build
-# runs-on: [self-hosted, X64, hathach, hardware-in-the-loop]
-# steps:
-# - name: Get Skip Boards from previous run
-# if: github.run_attempt != '1'
-# run: |
-# if [ -f "${{ env.HIL_JSON }}.skip" ]; then
-# SKIP_BOARDS=$(cat "${{ env.HIL_JSON }}.skip")
-# else
-# SKIP_BOARDS=""
-# fi
-# echo "SKIP_BOARDS=$SKIP_BOARDS"
-# echo "SKIP_BOARDS=$SKIP_BOARDS" >> $GITHUB_ENV
-#
-# - name: Clean workspace
-# run: |
-# echo "Cleaning up for the first run"
-# rm -rf "${{ github.workspace }}"
-# mkdir -p "${{ github.workspace }}"
-#
-# - name: Checkout TinyUSB
-# uses: actions/checkout@v4
-#
-# - name: Download Artifacts
-# uses: actions/download-artifact@v5
-# with:
-# path: cmake-build
-# merge-multiple: true
-#
-# - name: Test on actual hardware
-# run: |
-# python3 test/hil/hil_test.py ${{ env.HIL_JSON }} $SKIP_BOARDS
-#
-# # ---------------------------------------
-# # Hardware in the loop (HIL)
-# # self-hosted by HFP, build with IAR toolchain, for attached hardware checkout test/hil/hfp.json
-# # Since IAR Token secret is not passed to forked PR, only build non-forked PR
-# # ---------------------------------------
-# hil-hfp:
-# if: |
-# github.repository_owner == 'hathach' &&
-# github.event.pull_request.head.repo.fork == false &&
-# (github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch')
-# runs-on: [self-hosted, Linux, X64, hifiphile]
-# env:
-# IAR_LMS_BEARER_TOKEN: ${{ secrets.IAR_LMS_BEARER_TOKEN }}
-# steps:
-# - name: Clean workspace
-# run: |
-# echo "Cleaning up previous run"
-# rm -rf "${{ github.workspace }}"3
-# mkdir -p "${{ github.workspace }}"
-#
-# - name: Toolchain version
-# run: |
-# iccarm --version
-#
-# - name: Checkout TinyUSB
-# uses: actions/checkout@v4
-#
-# - name: Get build boards
-# run: |
-# MATRIX_JSON=$(python test/hil/hil_ci_set_matrix.py test/hil/hfp.json)
-# BUILD_ARGS=$(echo $MATRIX_JSON | jq -r '.["arm-gcc"] | join(" ")')
-# echo "BUILD_ARGS=$BUILD_ARGS"
-# echo "BUILD_ARGS=$BUILD_ARGS" >> $GITHUB_ENV
-#
-# - name: Get Dependencies
-# run: python3 tools/get_deps.py $BUILD_ARGS
-#
-# - name: Build
-# run: python3 tools/build.py -j 4 --toolchain iar $BUILD_ARGS
-#
-# - name: Test on actual hardware (hardware in the loop)
-# run: python3 test/hil/hil_test.py hfp.json
+ make:
+ if: github.event_name == 'push'
+ needs: set-matrix
+ uses: ./.github/workflows/build_util.yml
+ strategy:
+ fail-fast: false
+ matrix:
+ toolchain:
+ - 'aarch64-gcc'
+ #- 'arm-clang'
+ - 'arm-gcc'
+ - 'msp430-gcc'
+ - 'riscv-gcc'
+ - 'rx-gcc'
+ with:
+ build-system: 'make'
+ toolchain: ${{ matrix.toolchain }}
+ build-args: ${{ toJSON(fromJSON(needs.set-matrix.outputs.json)[matrix.toolchain]) }}
+ one-per-family: true
+
+ # ---------------------------------------
+ # Build IAR
+ # Since IAR Token secret is not passed to forked PR, only build non-forked PR with make.
+ # cmake is built by circle-ci. Due to IAR limit capacity, only build oe per family
+ # ---------------------------------------
+ arm-iar:
+ if: false # disable for now since we got reach capacity limit too often
+ #if: github.event_name == 'push' && github.repository_owner == 'hathach'
+ needs: set-matrix
+ uses: ./.github/workflows/build_util.yml
+ secrets: inherit
+ strategy:
+ fail-fast: false
+ matrix:
+ build-system:
+ - 'make'
+ with:
+ build-system: ${{ matrix.build-system }}
+ toolchain: 'arm-iar'
+ build-args: ${{ toJSON(fromJSON(needs.set-matrix.outputs.json)['arm-iar']) }}
+ one-per-family: true
+
+ # ---------------------------------------
+ # Build Make/CMake on Windows/MacOS
+ # ---------------------------------------
+ build-os:
+ if: github.event_name == 'pull_request'
+ uses: ./.github/workflows/build_util.yml
+ strategy:
+ fail-fast: false
+ matrix:
+ os: [ windows-latest, macos-latest ]
+ build-system: [ 'make', 'cmake' ]
+ with:
+ os: ${{ matrix.os }}
+ build-system: ${{ matrix.build-system }}
+ toolchain: 'arm-gcc-${{ matrix.os }}'
+ build-args: '["stm32h7"]'
+ one-per-family: true
+
+ # ---------------------------------------
+ # Zephyr
+ # ---------------------------------------
+ zephyr:
+ if: github.event_name == 'push'
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout TinyUSB
+ uses: actions/checkout@v4
+
+ - name: Setup Zephyr project
+ uses: zephyrproject-rtos/action-zephyr-setup@v1
+ with:
+ app-path: examples
+ toolchains: arm-zephyr-eabi
+
+ - name: Build
+ run: |
+ west build -b nrf52840dk -d examples/device/cdc_msc/build examples/device/cdc_msc -- -DRTOS=zephyr
+ west build -b nrf52840dk -d examples/device/msc_dual_lun/build examples/device/msc_dual_lun -- -DRTOS=zephyr
+
+ # ---------------------------------------
+ # Hardware in the loop (HIL)
+ # Run on PR only (hil-tinyusb), hil-hfp only run on non-forked PR
+ # ---------------------------------------
+ hil-build:
+ if: |
+ github.repository_owner == 'hathach' &&
+ (github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch')
+ needs: set-matrix
+ uses: ./.github/workflows/build_util.yml
+ strategy:
+ fail-fast: false
+ matrix:
+ toolchain:
+ - 'arm-gcc'
+ - 'esp-idf'
+ with:
+ build-system: 'cmake'
+ toolchain: ${{ matrix.toolchain }}
+ build-args: ${{ toJSON(fromJSON(needs.set-matrix.outputs.hil_json)[matrix.toolchain]) }}
+ one-per-family: true
+ upload-artifacts: true
+
+ # ---------------------------------------
+ # Hardware in the loop (HIL)
+ # self-hosted on local VM, for attached hardware checkout HIL_JSON
+ # ---------------------------------------
+ hil-tinyusb:
+ if: |
+ github.repository_owner == 'hathach' &&
+ (github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch')
+ needs: hil-build
+ runs-on: [ self-hosted, X64, hathach, hardware-in-the-loop ]
+ steps:
+ - name: Get Skip Boards from previous run
+ if: github.run_attempt != '1'
+ run: |
+ if [ -f "${{ env.HIL_JSON }}.skip" ]; then
+ SKIP_BOARDS=$(cat "${{ env.HIL_JSON }}.skip")
+ else
+ SKIP_BOARDS=""
+ fi
+ echo "SKIP_BOARDS=$SKIP_BOARDS"
+ echo "SKIP_BOARDS=$SKIP_BOARDS" >> $GITHUB_ENV
+
+ - name: Clean workspace
+ run: |
+ echo "Cleaning up for the first run"
+ rm -rf "${{ github.workspace }}"
+ mkdir -p "${{ github.workspace }}"
+
+ - name: Checkout TinyUSB
+ uses: actions/checkout@v4
+
+ - name: Download Artifacts
+ uses: actions/download-artifact@v5
+ with:
+ path: cmake-build
+ merge-multiple: true
+
+ - name: Test on actual hardware
+ run: |
+ python3 test/hil/hil_test.py ${{ env.HIL_JSON }} $SKIP_BOARDS
+
+ # ---------------------------------------
+ # Hardware in the loop (HIL)
+ # self-hosted by HFP, build with IAR toolchain, for attached hardware checkout test/hil/hfp.json
+ # Since IAR Token secret is not passed to forked PR, only build non-forked PR
+ # ---------------------------------------
+ hil-hfp:
+ if: |
+ github.repository_owner == 'hathach' &&
+ github.event.pull_request.head.repo.fork == false &&
+ (github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch')
+ runs-on: [ self-hosted, Linux, X64, hifiphile ]
+ env:
+ IAR_LMS_BEARER_TOKEN: ${{ secrets.IAR_LMS_BEARER_TOKEN }}
+ steps:
+ - name: Clean workspace
+ run: |
+ echo "Cleaning up previous run"
+ rm -rf "${{ github.workspace }}"3
+ mkdir -p "${{ github.workspace }}"
+
+ - name: Toolchain version
+ run: |
+ iccarm --version
+
+ - name: Checkout TinyUSB
+ uses: actions/checkout@v4
+
+ - name: Get build boards
+ run: |
+ MATRIX_JSON=$(python test/hil/hil_ci_set_matrix.py test/hil/hfp.json)
+ BUILD_ARGS=$(echo $MATRIX_JSON | jq -r '.["arm-gcc"] | join(" ")')
+ echo "BUILD_ARGS=$BUILD_ARGS"
+ echo "BUILD_ARGS=$BUILD_ARGS" >> $GITHUB_ENV
+
+ - name: Get Dependencies
+ run: python3 tools/get_deps.py $BUILD_ARGS
+
+ - name: Build
+ run: python3 tools/build.py -j 4 --toolchain iar $BUILD_ARGS
+
+ - name: Test on actual hardware (hardware in the loop)
+ run: python3 test/hil/hil_test.py hfp.json
diff --git a/.github/workflows/build_util.yml b/.github/workflows/build_util.yml
index 2de0ed229..36043a1d5 100644
--- a/.github/workflows/build_util.yml
+++ b/.github/workflows/build_util.yml
@@ -75,7 +75,7 @@ jobs:
- name: Upload Artifacts for Metrics
if: ${{ inputs.upload-metrics }}
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@v5
with:
name: metrics-${{ matrix.arg }}
path: cmake-build/cmake-build-*/metrics.json
diff --git a/.github/workflows/ci_set_matrix.py b/.github/workflows/ci_set_matrix.py
index 5032c83ae..933a8375f 100755
--- a/.github/workflows/ci_set_matrix.py
+++ b/.github/workflows/ci_set_matrix.py
@@ -20,8 +20,7 @@ family_list = {
"ch32v10x ch32v20x ch32v30x fomu gd32vf103": ["riscv-gcc"],
"imxrt": ["arm-gcc", "arm-clang"],
"kinetis_k kinetis_kl kinetis_k32l2": ["arm-gcc", "arm-clang"],
- "lpc11 lpc13 lpc15": ["arm-gcc", "arm-clang"],
- "lpc17 lpc18 lpc40 lpc43": ["arm-gcc", "arm-clang"],
+ "lpc11 lpc13 lpc15 lpc17 lpc18 lpc40 lpc43": ["arm-gcc", "arm-clang"],
"lpc51 lpc54 lpc55": ["arm-gcc", "arm-clang"],
"maxim mcx mm32 msp432e4 tm4c": ["arm-gcc"],
"msp430": ["msp430-gcc"],
@@ -36,8 +35,7 @@ family_list = {
"stm32f7": ["arm-gcc", "arm-clang", "arm-iar"],
"stm32g0 stm32g4 stm32h5": ["arm-gcc", "arm-clang", "arm-iar"],
"stm32h7": ["arm-gcc", "arm-clang", "arm-iar"],
- "stm32h7rs": ["arm-gcc", "arm-clang", "arm-iar"],
- "stm32l0 stm32l4": ["arm-gcc", "arm-clang", "arm-iar"],
+ "stm32h7rs stm32l0 stm32l4": ["arm-gcc", "arm-clang", "arm-iar"],
"stm32n6": ["arm-gcc"],
"stm32u0 stm32u5 stm32wb stm32wba": ["arm-gcc", "arm-clang", "arm-iar"],
"-bespressif_s2_devkitc": ["esp-idf"],
diff --git a/examples/CMakeLists.txt b/examples/CMakeLists.txt
index 694681467..b34131c2b 100644
--- a/examples/CMakeLists.txt
+++ b/examples/CMakeLists.txt
@@ -20,9 +20,9 @@ endforeach ()
# Post-build: run metrics.py on all map.json files
find_package(Python3 REQUIRED COMPONENTS Interpreter)
-add_custom_target(tinyusb_examples_metrics
+add_custom_target(tinyusb_metrics
COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/../tools/metrics.py
- -f tinyusb/src -j -o ${CMAKE_BINARY_DIR}/metrics
+ combine -f tinyusb/src -j -o ${CMAKE_BINARY_DIR}/metrics
${MAPJSON_PATTERNS}
COMMENT "Generating average code size metrics"
VERBATIM
diff --git a/hw/bsp/family_support.cmake b/hw/bsp/family_support.cmake
index 3ede95e3f..15d9f1eae 100644
--- a/hw/bsp/family_support.cmake
+++ b/hw/bsp/family_support.cmake
@@ -351,8 +351,10 @@ function(family_configure_common TARGET RTOS)
endif ()
endif ()
- # Generate linkermap target and post build. LINKERMAP_OPTION can be set with -D to change default options
- family_add_linkermap(${TARGET})
+ if (NOT RTOS STREQUAL zephyr)
+ # Generate linkermap target and post build. LINKERMAP_OPTION can be set with -D to change default options
+ family_add_linkermap(${TARGET})
+ endif ()
# run size after build
# find_program(SIZE_EXE ${CMAKE_SIZE})
diff --git a/tools/build.py b/tools/build.py
index b87af6c6a..e4909f45f 100755
--- a/tools/build.py
+++ b/tools/build.py
@@ -122,7 +122,7 @@ def cmake_board(board, build_args, build_flags_on):
rcmd = run_cmd(cmd)
if rcmd.returncode == 0:
ret[0] += 1
- run_cmd(["cmake", "--build", build_dir, '--target', 'tinyusb_examples_metrics'])
+ run_cmd(["cmake", "--build", build_dir, '--target', 'tinyusb_metrics'])
# print(rcmd.stdout.decode("utf-8"))
else:
ret[1] += 1
diff --git a/tools/get_deps.py b/tools/get_deps.py
index 9634451e2..99e406ce7 100755
--- a/tools/get_deps.py
+++ b/tools/get_deps.py
@@ -15,7 +15,7 @@ deps_mandatory = {
'159e31b689577dbf69cf0683bbaffbd71fa5ee10',
'all'],
'tools/linkermap': ['https://github.com/hathach/linkermap.git',
- '46c3c2947db366fb66af6723709febf80d860bc1',
+ '8a8206c39d0dfd7abfa615a676b3291165fcd65c',
'all'],
'tools/uf2': ['https://github.com/microsoft/uf2.git',
'c594542b2faa01cc33a2b97c9fbebc38549df80a',
diff --git a/tools/metrics.py b/tools/metrics.py
index c6cd49d57..7e54531f5 100644
--- a/tools/metrics.py
+++ b/tools/metrics.py
@@ -3,6 +3,7 @@
import argparse
import glob
+import json
import sys
import os
@@ -39,8 +40,6 @@ def combine_maps(map_files, filters=None):
Returns:
all_json_data: Dictionary with mapfiles list and data from each map file
"""
- import json
-
filters = filters or []
all_json_data = {"mapfiles": [], "data": []}
@@ -128,24 +127,185 @@ def compute_avg(all_json_data):
return json_average
-def main(argv=None):
- parser = argparse.ArgumentParser(description='Calculate average size from linker map files')
- parser.add_argument('files', nargs='+', help='Path to map file(s) or glob pattern(s)')
- parser.add_argument('-f', '--filter', dest='filters', action='append', default=[],
- help='Only include object files whose path contains this substring (can be repeated)')
- parser.add_argument('-o', '--out', dest='out', default='metrics',
- help='Output path basename for JSON and Markdown files (default: metrics)')
- parser.add_argument('-j', '--json', dest='json_out', action='store_true',
- help='Write JSON output file')
- parser.add_argument('-m', '--markdown', dest='markdown_out', action='store_true',
- help='Write Markdown output file')
- parser.add_argument('-q', '--quiet', dest='quiet', action='store_true',
- help='Suppress summary output')
- args = parser.parse_args(argv)
+def compare_maps(base_file, new_file, filters=None):
+ """Compare two map/json files and generate difference report.
- # Expand glob patterns
- map_files = expand_files(args.files)
+ Args:
+ base_file: Path to base map/json file
+ new_file: Path to new map/json file
+ filters: List of path substrings to filter object files
+
+ Returns:
+ Dictionary with comparison data
+ """
+ filters = filters or []
+
+ # Load both files
+ base_data = combine_maps([base_file], filters)
+ new_data = combine_maps([new_file], filters)
+
+ if not base_data["data"] or not new_data["data"]:
+ return None
+
+ base_avg = compute_avg(base_data)
+ new_avg = compute_avg(new_data)
+
+ if not base_avg or not new_avg:
+ return None
+
+ # Collect all sections from both
+ all_sections = list(base_avg["sections"])
+ for s in new_avg["sections"]:
+ if s not in all_sections:
+ all_sections.append(s)
+
+ # Build file lookup
+ base_files = {f["file"]: f for f in base_avg["files"]}
+ new_files = {f["file"]: f for f in new_avg["files"]}
+
+ # Get all file names
+ all_file_names = set(base_files.keys()) | set(new_files.keys())
+
+ # Build comparison data
+ comparison = []
+ for fname in sorted(all_file_names):
+ base_f = base_files.get(fname)
+ new_f = new_files.get(fname)
+
+ row = {"file": fname, "sections": {}, "total": {}}
+
+ for section in all_sections:
+ base_val = base_f["sections"].get(section, 0) if base_f else 0
+ new_val = new_f["sections"].get(section, 0) if new_f else 0
+ row["sections"][section] = {"base": base_val, "new": new_val, "diff": new_val - base_val}
+
+ base_total = base_f["total"] if base_f else 0
+ new_total = new_f["total"] if new_f else 0
+ row["total"] = {"base": base_total, "new": new_total, "diff": new_total - base_total}
+
+ comparison.append(row)
+
+ return {
+ "base_file": base_file,
+ "new_file": new_file,
+ "sections": all_sections,
+ "files": comparison
+ }
+
+
+def format_diff(base, new, diff):
+ """Format a diff value with percentage."""
+ if base == 0 and new == 0:
+ return "0"
+ if base == 0:
+ return f"{new} (new)"
+ if new == 0:
+ return f"{base} ➡ 0"
+ if diff == 0:
+ return f"{base} ➡ {new}"
+ pct = (diff / base) * 100
+ sign = "+" if diff > 0 else ""
+ return f"{base} ➡ {new} ({sign}{diff}, {sign}{pct:.1f}%)"
+
+
+def get_sort_key(sort_order):
+ """Get sort key function based on sort order.
+
+ Args:
+ sort_order: One of 'size-', 'size+', 'name-', 'name+'
+ Returns:
+ Tuple of (key_func, reverse)
+ """
+ if sort_order == 'size-':
+ return lambda x: x.get('total', 0) if isinstance(x.get('total'), int) else x['total']['new'], True
+ elif sort_order == 'size+':
+ return lambda x: x.get('total', 0) if isinstance(x.get('total'), int) else x['total']['new'], False
+ elif sort_order == 'name-':
+ return lambda x: x.get('file', ''), True
+ else: # name+
+ return lambda x: x.get('file', ''), False
+
+
+def write_compare_markdown(comparison, path, sort_order='size'):
+ """Write comparison data to markdown file."""
+ sections = comparison["sections"]
+
+ md_lines = [
+ "# TinyUSB Code Size Different Report",
+ "",
+ f"**Base:** `{comparison['base_file']}`",
+ f"**New:** `{comparison['new_file']}`",
+ "",
+ ]
+
+ # Build header
+ header = "| File |"
+ separator = "|:-----|"
+ for s in sections:
+ header += f" {s} |"
+ separator += "-----:|"
+ header += " Total |"
+ separator += "------:|"
+
+ md_lines.append(header)
+ md_lines.append(separator)
+
+ # Sort files based on sort_order
+ if sort_order == 'size-':
+ key_func = lambda x: abs(x["total"]["diff"])
+ reverse = True
+ elif sort_order in ('size', 'size+'):
+ key_func = lambda x: abs(x["total"]["diff"])
+ reverse = False
+ elif sort_order == 'name-':
+ key_func = lambda x: x['file']
+ reverse = True
+ else: # name or name+
+ key_func = lambda x: x['file']
+ reverse = False
+ sorted_files = sorted(comparison["files"], key=key_func, reverse=reverse)
+
+ sum_base = {s: 0 for s in sections}
+ sum_base["total"] = 0
+ sum_new = {s: 0 for s in sections}
+ sum_new["total"] = 0
+
+ for f in sorted_files:
+ # Skip files with no changes
+ if f["total"]["diff"] == 0 and all(f["sections"][s]["diff"] == 0 for s in sections):
+ continue
+
+ row = f"| {f['file']} |"
+ for s in sections:
+ sd = f["sections"][s]
+ sum_base[s] += sd["base"]
+ sum_new[s] += sd["new"]
+ row += f" {format_diff(sd['base'], sd['new'], sd['diff'])} |"
+
+ td = f["total"]
+ sum_base["total"] += td["base"]
+ sum_new["total"] += td["new"]
+ row += f" {format_diff(td['base'], td['new'], td['diff'])} |"
+
+ md_lines.append(row)
+
+ # Add sum row
+ sum_row = "| **SUM** |"
+ for s in sections:
+ diff = sum_new[s] - sum_base[s]
+ sum_row += f" {format_diff(sum_base[s], sum_new[s], diff)} |"
+ total_diff = sum_new["total"] - sum_base["total"]
+ sum_row += f" {format_diff(sum_base['total'], sum_new['total'], total_diff)} |"
+ md_lines.append(sum_row)
+
+ with open(path, "w", encoding="utf-8") as f:
+ f.write("\n".join(md_lines))
+
+
+def cmd_combine(args):
+ """Handle combine subcommand."""
+ map_files = expand_files(args.files)
all_json_data = combine_maps(map_files, args.filters)
json_average = compute_avg(all_json_data)
@@ -154,11 +314,65 @@ def main(argv=None):
sys.exit(1)
if not args.quiet:
- linkermap.print_summary(json_average, False)
+ linkermap.print_summary(json_average, False, args.sort)
if args.json_out:
linkermap.write_json(json_average, args.out + '.json')
if args.markdown_out:
- linkermap.write_markdown(json_average, args.out + '.md')
+ linkermap.write_markdown(json_average, args.out + '.md', sort_opt=args.sort,
+ title="TinyUSB Average Code Size Metrics")
+
+
+def cmd_compare(args):
+ """Handle compare subcommand."""
+ comparison = compare_maps(args.base, args.new, args.filters)
+
+ if comparison is None:
+ print("Failed to compare files", file=sys.stderr)
+ sys.exit(1)
+
+ write_compare_markdown(comparison, args.out + '.md', args.sort)
+ print(f"Comparison written to {args.out}.md")
+
+
+def main(argv=None):
+ parser = argparse.ArgumentParser(description='Code size metrics tool')
+ subparsers = parser.add_subparsers(dest='command', required=True, help='Available commands')
+
+ # Combine subcommand
+ combine_parser = subparsers.add_parser('combine', help='Combine and average multiple map files')
+ combine_parser.add_argument('files', nargs='+', help='Path to map file(s) or glob pattern(s)')
+ combine_parser.add_argument('-f', '--filter', dest='filters', action='append', default=[],
+ help='Only include object files whose path contains this substring (can be repeated)')
+ combine_parser.add_argument('-o', '--out', dest='out', default='metrics',
+ help='Output path basename for JSON and Markdown files (default: metrics)')
+ combine_parser.add_argument('-j', '--json', dest='json_out', action='store_true',
+ help='Write JSON output file')
+ combine_parser.add_argument('-m', '--markdown', dest='markdown_out', action='store_true',
+ help='Write Markdown output file')
+ combine_parser.add_argument('-q', '--quiet', dest='quiet', action='store_true',
+ help='Suppress summary output')
+ combine_parser.add_argument('-S', '--sort', dest='sort', default='name+',
+ choices=['size', 'size-', 'size+', 'name', 'name-', 'name+'],
+ help='Sort order: size/size- (descending), size+ (ascending), name/name+ (ascending), name- (descending). Default: name+')
+
+ # Compare subcommand
+ compare_parser = subparsers.add_parser('compare', help='Compare two map files')
+ compare_parser.add_argument('base', help='Base map/json file')
+ compare_parser.add_argument('new', help='New map/json file')
+ compare_parser.add_argument('-f', '--filter', dest='filters', action='append', default=[],
+ help='Only include object files whose path contains this substring (can be repeated)')
+ compare_parser.add_argument('-o', '--out', dest='out', default='metrics_compare',
+ help='Output path basename for Markdown file (default: metrics_compare)')
+ compare_parser.add_argument('-S', '--sort', dest='sort', default='name+',
+ choices=['size', 'size-', 'size+', 'name', 'name-', 'name+'],
+ help='Sort order: size/size- (descending), size+ (ascending), name/name+ (ascending), name- (descending). Default: name+')
+
+ args = parser.parse_args(argv)
+
+ if args.command == 'combine':
+ cmd_combine(args)
+ elif args.command == 'compare':
+ cmd_compare(args)
if __name__ == '__main__':