From: Simon Glass <simon.glass@canonical.com> When analysing large codebases, it is useful to group source files by functional area such as boot, drivers, or networking. Add category.py module to match source files to categories and features defined in a TOML configuration file. The module supports three pattern types: exact paths, glob patterns, and directory prefixes. An ignore list allows excluding external code from reports. Add unit tests for the category-matching functions and document the category system in codman.rst Co-developed-by: Claude Opus 4.5 <noreply@anthropic.com> Signed-off-by: Simon Glass <simon.glass@canonical.com> --- tools/codman/category.py | 147 +++++++++++++++++++++ tools/codman/codman.rst | 41 ++++++ tools/codman/test_category.py | 234 ++++++++++++++++++++++++++++++++++ 3 files changed, 422 insertions(+) create mode 100644 tools/codman/category.py create mode 100644 tools/codman/test_category.py diff --git a/tools/codman/category.py b/tools/codman/category.py new file mode 100644 index 00000000000..b7b2ecb93ac --- /dev/null +++ b/tools/codman/category.py @@ -0,0 +1,147 @@ +# SPDX-License-Identifier: GPL-2.0 +# +# Copyright 2025 Canonical Ltd +# +"""Category and feature management for codman. + +This module provides functions for loading category configuration and +matching source files to features/categories. +""" + +from collections import namedtuple +import fnmatch +import os + +try: + import tomllib +except ImportError: + import tomli as tomllib + +from u_boot_pylib import tools + +# Return type for load_category_config functions +CategoryConfig = namedtuple('CategoryConfig', + ['categories', 'features', 'ignore']) + + +def load_category_config(srcdir): + """Load category configuration from category.cfg. + + Args: + srcdir (str): Root directory of the source tree + + Returns: + CategoryConfig: namedtuple with (categories, features, ignore) or + None if not found + """ + cfg_path = os.path.join(srcdir, 'tools', 'codman', 'category.cfg') + if not os.path.exists(cfg_path): + return None + + try: + data = tools.read_file(cfg_path, binary=False) + config = tomllib.loads(data) + ignore = config.get('ignore', {}).get('files', []) + return CategoryConfig(config.get('categories', {}), + config.get('features', {}), ignore) + except (IOError, tomllib.TOMLDecodeError): + return None + + +def load_config_file(cfg_path): + """Load category configuration from a specific file path. + + Args: + cfg_path (str): Path to the category configuration file + + Returns: + CategoryConfig: namedtuple with (categories, features, ignore) or + None if not found + """ + if not os.path.exists(cfg_path): + return None + + try: + data = tools.read_file(cfg_path, binary=False) + config = tomllib.loads(data) + ignore = config.get('ignore', {}).get('files', []) + return CategoryConfig(config.get('categories', {}), + config.get('features', {}), ignore) + except (IOError, tomllib.TOMLDecodeError): + return None + + +def should_ignore_file(filepath, ignore_patterns): + """Check if a file should be ignored based on ignore patterns. + + Args: + filepath (str): Relative file path to check + ignore_patterns (list): List of patterns to ignore + + Returns: + bool: True if file should be ignored + """ + if not ignore_patterns: + return False + + for pattern in ignore_patterns: + # Directory prefix: pattern ending with '/' matches all files under it + if pattern.endswith('/'): + if filepath.startswith(pattern): + return True + # Exact match or glob pattern + elif fnmatch.fnmatch(filepath, pattern) or filepath == pattern: + return True + return False + + +def get_file_feature(filepath, features): + """Match a file path to a feature based on the feature's file patterns. + + Args: + filepath (str): Relative file path to match + features (dict): Features dict from category config + + Returns: + tuple: (feature_id, category_id) or (None, None) if no match + """ + for feat_id, feat_data in features.items(): + for pattern in feat_data.get('files', []): + # Directory prefix: pattern ending with '/' matches all under it + if pattern.endswith('/'): + if filepath.startswith(pattern): + return feat_id, feat_data.get('category') + # Exact match or glob pattern + elif fnmatch.fnmatch(filepath, pattern) or filepath == pattern: + return feat_id, feat_data.get('category') + return None, None + + +def get_category_desc(categories, category_id): + """Get the description for a category. + + Args: + categories (dict): Categories dict from category config + category_id (str): Category identifier + + Returns: + str: Category description or None if not found + """ + if categories and category_id in categories: + return categories[category_id].get('description') + return None + + +def get_feature_desc(features, feature_id): + """Get the description for a feature. + + Args: + features (dict): Features dict from category config + feature_id (str): Feature identifier + + Returns: + str: Feature description or None if not found + """ + if features and feature_id in features: + return features[feature_id].get('description') + return None diff --git a/tools/codman/codman.rst b/tools/codman/codman.rst index 7f5df3d1c6f..c651fd6514e 100644 --- a/tools/codman/codman.rst +++ b/tools/codman/codman.rst @@ -312,6 +312,47 @@ The HTML report includes: This is useful for sharing reports or exploring large codebases interactively in a web browser. +Categories and Features +----------------------- + +Codman can categorise source files into functional areas using the +``tools/codman/category.cfg`` configuration file. This TOML file defines: + +**Categories**: High-level groupings like "load-boot", "storage", "drivers" + +**Features**: Specific functional areas within categories, with file patterns +that define which source files belong to each feature. + +The configuration uses three types of file patterns: + +* Exact paths: ``"boot/bootm.c"`` +* Glob patterns: ``"drivers/video/*.c"`` +* Directory prefixes: ``"lib/acpi/"`` (matches all files under the directory) + +Example category.cfg structure:: + + [categories.load-boot] + description = "Loading & Boot" + + [features.boot-linux-direct] + category = "load-boot" + description = "Direct Linux boot" + files = [ + "boot/bootm.c", + "boot/bootm_os.c", + "boot/image-board.c", + ] + +**Ignoring External Code** + +The ``[ignore]`` section in category.cfg can exclude external/vendored code +from reports:: + + [ignore] + files = [ + "lib/lwip/lwip/", # External lwIP library + ] + Unused Files (``unused``) ------------------------- diff --git a/tools/codman/test_category.py b/tools/codman/test_category.py new file mode 100644 index 00000000000..3ce89d70b18 --- /dev/null +++ b/tools/codman/test_category.py @@ -0,0 +1,234 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: GPL-2.0+ +# +# Copyright 2025 Canonical Ltd +# +"""Unit tests for category.py module""" + +import os +import shutil +import sys +import tempfile +import unittest + +# Test configuration +SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) + +# Import the module to test +sys.path.insert(0, SCRIPT_DIR) +sys.path.insert(0, os.path.join(SCRIPT_DIR, '..')) +import category # pylint: disable=wrong-import-position +from u_boot_pylib import tools # pylint: disable=wrong-import-position + + +class TestMatchFileToFeature(unittest.TestCase): + """Test cases for get_file_feature function""" + + def test_exact_match(self): + """Test exact file path matching""" + features = { + 'test-feature': { + 'category': 'test-cat', + 'files': ['path/to/file.c'], + } + } + feat_id, cat_id = category.get_file_feature( + 'path/to/file.c', features) + self.assertEqual(feat_id, 'test-feature') + self.assertEqual(cat_id, 'test-cat') + + def test_glob_pattern(self): + """Test glob pattern matching""" + features = { + 'test-feature': { + 'category': 'test-cat', + 'files': ['drivers/video/*.c'], + } + } + feat_id, cat_id = category.get_file_feature( + 'drivers/video/console.c', features) + self.assertEqual(feat_id, 'test-feature') + self.assertEqual(cat_id, 'test-cat') + + def test_directory_prefix(self): + """Test directory prefix matching (pattern ending with /)""" + features = { + 'efi-loader': { + 'category': 'efi', + 'files': ['lib/efi_loader/'], + } + } + # Should match files directly in directory + feat_id, cat_id = category.get_file_feature( + 'lib/efi_loader/efi_acpi.c', features) + self.assertEqual(feat_id, 'efi-loader') + self.assertEqual(cat_id, 'efi') + + # Should match files in subdirectories + feat_id, cat_id = category.get_file_feature( + 'lib/efi_loader/subdir/file.c', features) + self.assertEqual(feat_id, 'efi-loader') + self.assertEqual(cat_id, 'efi') + + def test_no_match(self): + """Test when no feature matches""" + features = { + 'test-feature': { + 'category': 'test-cat', + 'files': ['other/path/*.c'], + } + } + feat_id, cat_id = category.get_file_feature( + 'different/path/file.c', features) + self.assertIsNone(feat_id) + self.assertIsNone(cat_id) + + def test_empty_features(self): + """Test with empty features dict""" + feat_id, cat_id = category.get_file_feature('any/file.c', {}) + self.assertIsNone(feat_id) + self.assertIsNone(cat_id) + + def test_feature_without_files(self): + """Test feature with empty files list""" + features = { + 'test-feature': { + 'category': 'test-cat', + 'files': [], + } + } + feat_id, cat_id = category.get_file_feature( + 'any/file.c', features) + self.assertIsNone(feat_id) + self.assertIsNone(cat_id) + + def test_first_match_wins(self): + """Test that first matching feature is returned""" + features = { + 'feature-a': { + 'category': 'cat-a', + 'files': ['lib/'], + }, + 'feature-b': { + 'category': 'cat-b', + 'files': ['lib/specific.c'], + } + } + # Order depends on dict iteration, but one should match + feat_id, _ = category.get_file_feature( + 'lib/specific.c', features) + self.assertIsNotNone(feat_id) + self.assertIn(feat_id, ['feature-a', 'feature-b']) + + +class TestLoadCategoryConfig(unittest.TestCase): + """Test cases for load_category_config functions""" + + def setUp(self): + """Create temporary directory for test files""" + self.test_dir = tempfile.mkdtemp(prefix='test_category_') + + def tearDown(self): + """Clean up temporary directory""" + if os.path.exists(self.test_dir): + shutil.rmtree(self.test_dir) + + def test_load_valid_config(self): + """Test loading a valid TOML config file""" + cfg_content = ''' +[categories.load-boot] +description = "Loading & Boot" + +[features.boot-direct] +category = "load-boot" +description = "Direct boot" +files = ["boot/bootm.c"] +''' + cfg_path = os.path.join(self.test_dir, 'category.cfg') + tools.write_file(cfg_path, cfg_content, binary=False) + + result = category.load_config_file(cfg_path) + + self.assertIsNotNone(result) + self.assertIn('load-boot', result.categories) + self.assertEqual(result.categories['load-boot']['description'], + 'Loading & Boot') + self.assertIn('boot-direct', result.features) + self.assertEqual(result.features['boot-direct']['category'], + 'load-boot') + + def test_load_missing_file(self): + """Test loading from non-existent file""" + cfg_path = os.path.join(self.test_dir, 'nonexistent.cfg') + result = category.load_config_file(cfg_path) + self.assertIsNone(result) + + def test_load_invalid_toml(self): + """Test loading invalid TOML file""" + cfg_path = os.path.join(self.test_dir, 'invalid.cfg') + tools.write_file(cfg_path, 'this is not valid TOML [[[', binary=False) + result = category.load_config_file(cfg_path) + self.assertIsNone(result) + + def test_load_from_srcdir(self): + """Test load_category_config with srcdir parameter""" + # Create tools/codman directory structure + codman_dir = os.path.join(self.test_dir, 'tools', 'codman') + os.makedirs(codman_dir) + + cfg_content = ''' +[categories.test] +description = "Test category" + +[features.test-feat] +category = "test" +description = "Test feature" +files = [] +''' + cfg_path = os.path.join(codman_dir, 'category.cfg') + tools.write_file(cfg_path, cfg_content, binary=False) + + result = category.load_category_config(self.test_dir) + + self.assertIsNotNone(result) + self.assertIn('test', result.categories) + + +class TestHelperFunctions(unittest.TestCase): + """Test cases for helper functions""" + + def test_get_category_desc(self): + """Test get_category_desc function""" + categories = { + 'load-boot': {'description': 'Loading & Boot'}, + 'storage': {'description': 'Storage'}, + } + desc = category.get_category_desc(categories, 'load-boot') + self.assertEqual(desc, 'Loading & Boot') + + desc = category.get_category_desc(categories, 'nonexistent') + self.assertIsNone(desc) + + desc = category.get_category_desc(None, 'load-boot') + self.assertIsNone(desc) + + def test_get_feature_desc(self): + """Test get_feature_desc function""" + features = { + 'boot-direct': { + 'description': 'Direct boot', + 'category': 'load-boot', + }, + } + desc = category.get_feature_desc(features, 'boot-direct') + self.assertEqual(desc, 'Direct boot') + + desc = category.get_feature_desc(features, 'nonexistent') + self.assertIsNone(desc) + + desc = category.get_feature_desc(None, 'boot-direct') + self.assertIsNone(desc) + + +if __name__ == '__main__': + unittest.main() -- 2.43.0