Source code for curate_ipsum.regions.models
"""
Hierarchical code region model.
Inspired by pytest's node ID format (well-established, familiar to Python developers).
Supports serialization to/from strings for storage compatibility.
"""
from __future__ import annotations
from dataclasses import dataclass
from enum import StrEnum
[docs]
class RegionLevel(StrEnum):
"""Granularity level of a region."""
FILE = "file"
CLASS = "class"
FUNCTION = "func"
LINES = "lines"
[docs]
@dataclass(frozen=True)
class Region:
"""
Hierarchical code region identifier.
Immutable, hashable, and serializable to/from string.
Supports containment queries (is region A within region B?).
Format:
file:<path> # Whole file
file:<path>::class:<name> # Class level
file:<path>::func:<name> # Function level
file:<path>::class:<name>::func:<name> # Method within class
file:<path>::lines:<start>-<end> # Line range
Examples:
>>> Region.for_file("src/main.py")
Region('file:src/main.py')
>>> Region.for_function("src/main.py", "compute", class_name="Calculator")
Region('file:src/main.py::class:Calculator::func:compute')
>>> r = Region.from_string("file:src/main.py::lines:10-20")
>>> r.line_start
10
"""
file_path: str
class_name: str | None = None
func_name: str | None = None
line_start: int | None = None
line_end: int | None = None
def __post_init__(self) -> None:
"""Validate region fields."""
if not self.file_path:
raise ValueError("file_path cannot be empty")
if self.line_start is not None and self.line_end is not None:
if self.line_start > self.line_end:
raise ValueError(f"line_start ({self.line_start}) cannot be greater than line_end ({self.line_end})")
if self.line_start < 1:
raise ValueError(f"line_start must be >= 1, got {self.line_start}")
@property
def level(self) -> RegionLevel:
"""Most specific level of this region."""
if self.line_start is not None:
return RegionLevel.LINES
if self.func_name is not None:
return RegionLevel.FUNCTION
if self.class_name is not None:
return RegionLevel.CLASS
return RegionLevel.FILE
[docs]
def contains(self, other: "Region") -> bool:
"""
Check if this region contains another region.
A region contains another if:
- Same file AND
- This region's scope encompasses the other's scope
Examples:
file:a.py contains file:a.py::func:foo # True
file:a.py::class:C contains file:a.py::class:C::func:m # True
file:a.py::func:foo contains file:a.py # False
"""
# Must be same file
if self.file_path != other.file_path:
return False
# File contains everything in that file
if self.level == RegionLevel.FILE:
return True
# Class level containment
if self.level == RegionLevel.CLASS:
# Other must be in same class (or be the class itself)
if other.class_name != self.class_name:
return False
# Class contains its methods and line ranges within it
return True
# Function level containment
if self.level == RegionLevel.FUNCTION:
# Must match both class and function
if other.func_name != self.func_name:
return False
if other.class_name != self.class_name:
return False
# Function contains line ranges within it
return True
# Lines level containment
if self.level == RegionLevel.LINES and other.level == RegionLevel.LINES:
if self.line_start is None or self.line_end is None:
return False
if other.line_start is None or other.line_end is None:
return False
return self.line_start <= other.line_start and self.line_end >= other.line_end
return False
[docs]
def overlaps(self, other: "Region") -> bool:
"""
Check if this region overlaps with another.
Two regions overlap if they share any code locations.
"""
if self.file_path != other.file_path:
return False
# If either contains the other, they overlap
if self.contains(other) or other.contains(self):
return True
# Line range overlap check
if (
self.line_start is not None
and self.line_end is not None
and other.line_start is not None
and other.line_end is not None
):
# Check for non-overlap and negate
no_overlap = self.line_end < other.line_start or other.line_end < self.line_start
return not no_overlap
# Same function (regardless of line ranges)
if self.func_name and self.func_name == other.func_name:
if self.class_name == other.class_name:
return True
# Same class (regardless of function)
if self.class_name and self.class_name == other.class_name:
# Only if neither specifies a different function
if self.func_name is None or other.func_name is None:
return True
if self.func_name == other.func_name:
return True
return False
[docs]
def to_string(self) -> str:
"""
Serialize to canonical string format.
The format is designed to be:
- Human readable
- Lexicographically sortable by file
- Parseable back to Region
"""
parts = [f"file:{self.file_path}"]
if self.class_name:
parts.append(f"class:{self.class_name}")
if self.func_name:
parts.append(f"func:{self.func_name}")
if self.line_start is not None:
if self.line_end is not None and self.line_end != self.line_start:
parts.append(f"lines:{self.line_start}-{self.line_end}")
else:
parts.append(f"lines:{self.line_start}-{self.line_start}")
return "::".join(parts)
[docs]
@classmethod
def from_string(cls, s: str) -> "Region":
"""
Parse from canonical string format.
Also handles legacy plain file paths for backward compatibility.
Args:
s: Region string like "file:src/main.py::func:compute"
or legacy plain path like "src/main.py"
Returns:
Parsed Region instance
Raises:
ValueError: If string is empty or malformed
"""
s = s.strip()
if not s:
raise ValueError("Empty region string")
# Handle legacy plain strings (backward compatibility)
if not s.startswith("file:"):
# Assume it's a file path or legacy region ID
return cls(file_path=s)
file_path: str | None = None
class_name: str | None = None
func_name: str | None = None
line_start: int | None = None
line_end: int | None = None
parts = s.split("::")
for part in parts:
if part.startswith("file:"):
file_path = part[5:]
elif part.startswith("class:"):
class_name = part[6:]
elif part.startswith("func:"):
func_name = part[5:]
elif part.startswith("lines:"):
line_spec = part[6:]
if "-" in line_spec:
start_s, end_s = line_spec.split("-", 1)
line_start = int(start_s)
line_end = int(end_s)
else:
line_start = line_end = int(line_spec)
if file_path is None:
raise ValueError(f"No file path in region string: {s}")
return cls(
file_path=file_path,
class_name=class_name,
func_name=func_name,
line_start=line_start,
line_end=line_end,
)
[docs]
@classmethod
def for_file(cls, path: str) -> "Region":
"""Create a file-level region."""
return cls(file_path=path)
[docs]
@classmethod
def for_class(cls, path: str, class_name: str) -> "Region":
"""Create a class-level region."""
return cls(file_path=path, class_name=class_name)
[docs]
@classmethod
def for_function(
cls,
path: str,
func_name: str,
class_name: str | None = None,
) -> "Region":
"""Create a function-level region."""
return cls(file_path=path, class_name=class_name, func_name=func_name)
[docs]
@classmethod
def for_lines(
cls,
path: str,
start: int,
end: int,
func_name: str | None = None,
class_name: str | None = None,
) -> "Region":
"""Create a line-range region."""
return cls(
file_path=path,
class_name=class_name,
func_name=func_name,
line_start=start,
line_end=end,
)
def __str__(self) -> str:
return self.to_string()
def __repr__(self) -> str:
return f"Region({self.to_string()!r})"
[docs]
def normalize_region_id(region_id: str | None) -> Region | None:
"""
Convert legacy regionId string to Region, or return None.
Provides backward compatibility with existing regionId: str fields.
"""
if region_id is None:
return None
return Region.from_string(region_id)
[docs]
def region_to_id(region: Region | None) -> str | None:
"""
Convert Region back to string for storage.
Inverse of normalize_region_id.
"""
if region is None:
return None
return region.to_string()