Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,5 @@ new/

# Generated endpoints file (created by editable install)
src/golf/_endpoints.py
build.sh
build.sh
.claude
16 changes: 16 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,22 @@ Uses pytest with these key configurations:
- **Mypy** type checking with strict settings
- Configuration in `pyproject.toml`

## Git Commit Guidelines

Follow these commit message patterns when making changes:

- `fix[component]: description` - Bug fixes (e.g., `fix[parser]: handle edge case in import resolution`)
- `feat[component]: description` - New features (e.g., `feat[builder]: add shared file support`)
- `refactor[component]: description` - Code refactoring (e.g., `refactor[auth]: simplify provider creation`)
- `test[component]: description` - Test additions/changes (e.g., `test[core]: add integration tests`)
- `docs[component]: description` - Documentation updates (e.g., `docs[api]: update authentication guide`)
- `style[component]: description` - Code formatting (e.g., `style[core]: format with ruff`)

Examples from recent commits:
- `fix[parser]: add shared file discovery for enhanced imports`
- `fix[builder]: enhance import mapping for any shared file`
- `fix[transformer]: improve import transformation patterns`

## Component System

Golf projects have this structure:
Expand Down
90 changes: 48 additions & 42 deletions src/golf/core/builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -304,7 +304,7 @@ def __init__(
self.copy_env = copy_env
self.components = {}
self.manifest = {}
self.common_files = {}
self.shared_files = {}
self.import_map = {}

def generate(self) -> None:
Expand All @@ -314,9 +314,11 @@ def generate(self) -> None:
self.components = parse_project(self.project_path)
self.manifest = build_manifest(self.project_path, self.settings)

# Find common.py files and build import map
self.common_files = find_common_files(self.project_path, self.components)
self.import_map = build_import_map(self.project_path, self.common_files)
# Find shared Python files and build import map
from golf.core.parser import parse_shared_files

self.shared_files = parse_shared_files(self.project_path)
self.import_map = build_import_map(self.project_path, self.shared_files)

# Create output directory structure
with console.status("Creating directory structure..."):
Expand Down Expand Up @@ -359,20 +361,20 @@ def _create_directory_structure(self) -> None:

for directory in dirs:
directory.mkdir(parents=True, exist_ok=True)
# Process common.py files directly in the components directory
self._process_common_files()
# Process shared files directly in the components directory
self._process_shared_files()

def _process_common_files(self) -> None:
"""Process and transform common.py files in the components directory
def _process_shared_files(self) -> None:
"""Process and transform shared Python files in the components directory
structure."""
# Reuse the already fetched common_files instead of calling the function again
for dir_path_str, common_file in self.common_files.items():
# Convert string path to Path object
dir_path = Path(dir_path_str)
# Process all shared files
for module_path_str, shared_file in self.shared_files.items():
# Convert module path to Path object (e.g., "tools/weather/helpers")
module_path = Path(module_path_str)

# Determine the component type
component_type = None
for part in dir_path.parts:
for part in module_path.parts:
if part in ["tools", "resources", "prompts"]:
component_type = part
break
Expand All @@ -381,22 +383,22 @@ def _process_common_files(self) -> None:
continue

# Calculate target directory in components structure
rel_to_component = dir_path.relative_to(component_type)
target_dir = self.output_dir / "components" / component_type / rel_to_component
rel_to_component = module_path.relative_to(component_type)
target_dir = self.output_dir / "components" / component_type / rel_to_component.parent

# Create directory if it doesn't exist
target_dir.mkdir(parents=True, exist_ok=True)

# Create the common.py file in the target directory
target_file = target_dir / "common.py"
# Create the shared file in the target directory (preserve original filename)
target_file = target_dir / shared_file.name

# Use transformer to process the file
transform_component(
component=None,
output_file=target_file,
project_path=self.project_path,
import_map=self.import_map,
source_file=common_file,
source_file=shared_file,
)

def _generate_tools(self) -> None:
Expand Down Expand Up @@ -1309,34 +1311,29 @@ def build_project(
)


# Renamed function - was find_shared_modules
def find_common_files(project_path: Path, components: dict[ComponentType, list[ParsedComponent]]) -> dict[str, Path]:
"""Find all common.py files used by components."""
# We'll use the parser's functionality to find common files directly
from golf.core.parser import parse_common_files

common_files = parse_common_files(project_path)
# Legacy function removed - replaced by parse_shared_files in parser module

# Return the found files without debug messages
return common_files


# Updated parameter name from shared_modules to common_files
def build_import_map(project_path: Path, common_files: dict[str, Path]) -> dict[str, str]:
# Updated to handle any shared file, not just common.py files
def build_import_map(project_path: Path, shared_files: dict[str, Path]) -> dict[str, str]:
"""Build a mapping of import paths to their new locations in the build output.

This maps from original relative import paths to absolute import paths
in the components directory structure.

Args:
project_path: Path to the project root
shared_files: Dictionary mapping module paths to shared file paths
"""
import_map = {}

for dir_path_str, _file_path in common_files.items():
# Convert string path to Path object
dir_path = Path(dir_path_str)
for module_path_str, file_path in shared_files.items():
# Convert module path to Path object (e.g., "tools/weather/helpers" -> Path("tools/weather/helpers"))
module_path = Path(module_path_str)

# Get the component type (tools, resources, prompts)
component_type = None
for part in dir_path.parts:
for part in module_path.parts:
if part in ["tools", "resources", "prompts"]:
component_type = part
break
Expand All @@ -1346,23 +1343,32 @@ def build_import_map(project_path: Path, common_files: dict[str, Path]) -> dict[

# Calculate the relative path within the component type
try:
rel_to_component = dir_path.relative_to(component_type)
rel_to_component = module_path.relative_to(component_type)
# Create the new import path
if str(rel_to_component) == ".":
# This is at the root of the component type
# This shouldn't happen for individual files, but handle it
new_path = f"components.{component_type}"
else:
# Replace path separators with dots
path_parts = str(rel_to_component).replace("\\", "/").split("/")
new_path = f"components.{component_type}.{'.'.join(path_parts)}"

# Map both the directory and the common file
orig_module = dir_path_str
import_map[orig_module] = new_path
# Map the specific shared module
# e.g., "tools/weather/helpers" -> "components.tools.weather.helpers"
import_map[module_path_str] = new_path

# Also map the directory path for relative imports
# e.g., "tools/weather" -> "components.tools.weather"
dir_path_str = str(module_path.parent)
if dir_path_str != "." and dir_path_str not in import_map:
dir_rel_to_component = module_path.parent.relative_to(component_type)
if str(dir_rel_to_component) == ".":
dir_new_path = f"components.{component_type}"
else:
dir_path_parts = str(dir_rel_to_component).replace("\\", "/").split("/")
dir_new_path = f"components.{component_type}.{'.'.join(dir_path_parts)}"
import_map[dir_path_str] = dir_new_path

# Also map the specific common module
common_module = f"{dir_path_str}/common"
import_map[common_module] = f"{new_path}.common"
except ValueError:
continue

Expand Down
68 changes: 68 additions & 0 deletions src/golf/core/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -982,3 +982,71 @@ def parse_common_files(project_path: Path) -> dict[str, Path]:
common_files[module_path] = common_file

return common_files


def _is_golf_component_file(file_path: Path) -> bool:
"""Check if a Python file is a Golf component (has export or resource_uri).

Args:
file_path: Path to the Python file to check

Returns:
True if the file appears to be a Golf component, False otherwise
"""
try:
with open(file_path, encoding="utf-8") as f:
content = f.read()

# Parse the file to check for Golf component patterns
tree = ast.parse(content)

# Look for 'export' or 'resource_uri' variable assignments
for node in ast.walk(tree):
if isinstance(node, ast.Assign):
for target in node.targets:
if isinstance(target, ast.Name):
if target.id in ("export", "resource_uri"):
return True

return False

except (SyntaxError, OSError, UnicodeDecodeError):
# If we can't parse the file, assume it's not a component
return False


def parse_shared_files(project_path: Path) -> dict[str, Path]:
"""Find all shared Python files in the project (non-component .py files).

Args:
project_path: Path to the project root

Returns:
Dictionary mapping module paths to shared file paths
"""
shared_files = {}

# Search for all .py files in tools, resources, and prompts directories
for dir_name in ["tools", "resources", "prompts"]:
base_dir = project_path / dir_name
if not base_dir.exists() or not base_dir.is_dir():
continue

# Find all .py files (recursively)
for py_file in base_dir.glob("**/*.py"):
# Skip files in __pycache__ or other hidden directories
if "__pycache__" in py_file.parts or any(part.startswith(".") for part in py_file.parts):
continue

# Skip files that are Golf components (have export or resource_uri)
if _is_golf_component_file(py_file):
continue

# Calculate the module path for this shared file
# For example: tools/weather/helpers.py -> tools/weather/helpers
relative_path = py_file.relative_to(project_path)
module_path = str(relative_path.with_suffix("")) # Remove .py extension

shared_files[module_path] = py_file

return shared_files
47 changes: 37 additions & 10 deletions src/golf/core/transformer.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,35 +51,62 @@ def visit_ImportFrom(self, node: ast.ImportFrom) -> Any:
source_dir = source_dir.parent

if node.module:
# Handle imports like `from .helpers import utils`
source_module = source_dir / node.module.replace(".", "/")
else:
# Handle imports like `from . import something`
source_module = source_dir

# Check if this is a common module import
source_str = str(source_module.relative_to(self.project_root))
if source_str in self.import_map:
# Replace with absolute import
new_module = self.import_map[source_str]
return ast.ImportFrom(module=new_module, names=node.names, level=0)
try:
# Check if this is a shared module import
source_str = str(source_module.relative_to(self.project_root))

# First, try direct module path match (e.g., "tools/weather/helpers")
if source_str in self.import_map:
new_module = self.import_map[source_str]
return ast.ImportFrom(module=new_module, names=node.names, level=0)

# If direct match fails, try directory-based matching
# This handles cases like `from . import common` where the import_map
# has "tools/weather/common" but we're looking for "tools/weather"
source_dir_str = str(source_dir.relative_to(self.project_root))
if source_dir_str in self.import_map:
new_module = self.import_map[source_dir_str]
if node.module:
new_module = f"{new_module}.{node.module}"
return ast.ImportFrom(module=new_module, names=node.names, level=0)

# Check for specific module imports within the directory
for import_path, mapped_path in self.import_map.items():
# Handle cases where we import a specific module from a directory
# e.g., `from .common import something` should match "tools/weather/common"
if import_path.startswith(source_dir_str + "/") and node.module:
module_name = import_path.replace(source_dir_str + "/", "")
if module_name == node.module:
return ast.ImportFrom(module=mapped_path, names=node.names, level=0)

except ValueError:
# source_module is not relative to project_root, leave import unchanged
pass

return node


def transform_component(
component: ParsedComponent,
component: ParsedComponent | None,
output_file: Path,
project_path: Path,
import_map: dict[str, str],
source_file: Path = None,
source_file: Path | None = None,
) -> str:
"""Transform a GolfMCP component into a standalone FastMCP component.

Args:
component: Parsed component to transform
component: Parsed component to transform (optional if source_file provided)
output_file: Path to write the transformed component to
project_path: Path to the project root
import_map: Mapping of original module paths to generated paths
source_file: Optional path to source file (for common.py files)
source_file: Optional path to source file (for shared files)

Returns:
Generated component code
Expand Down