Skip to content
Open
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,7 @@ terraform.rc

# Application specific
config/config.json
config/cyclecloud-credentials.json
# Runtime-generated script copies (orb init)
/scripts/
data/
Expand Down
133 changes: 133 additions & 0 deletions dev-tools/quality/quality_check.py
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,22 @@ def can_autofix(self) -> bool:
return True


class UnjustifiedGetAttrViolation(Violation):
"""getattr call without a justifying ``# getattr`` comment."""

def __init__(self, file_path: str, line_num: int, content: str):
super().__init__(
file_path,
line_num,
content,
"getattr should only be used when absolutely needed; "
"justify with a comment starting with '# getattr' on the same "
"or a preceding line if it is needed, remove getattr if not. Strongly prefer"
"removing, use web search to find the real API for external SDK calls. Do not replace"
"with an equivalent pattern that avoids getattr but is still the same anti-pattern.",
)


# --- Checker Classes ---


Expand Down Expand Up @@ -555,6 +571,120 @@ def check_content(self, file_path: str, content: str) -> list[Violation]:
return violations


class GetAttrChecker(FileChecker):
"""Enforce justified ``getattr`` use in Azure and GCP provider code.

Every ``getattr(...)`` call in ``src/orb/providers/azure/`` and
``src/orb/providers/gcp/`` must have a comment starting with ``# getattr``
on the same line, on a preceding line within the same scope, or in the
enclosing function/method docstring. This keeps defensive SDK access
intentional and documented at the provider boundary.
"""

_PROVIDER_PREFIXES = (
os.path.join("src", "orb", "providers", "azure"),
os.path.join("src", "orb", "providers", "gcp"),
)
_GETATTR_CALL = re.compile(r"\bgetattr\s*\(")
_JUSTIFICATION_COMMENT = re.compile(r"#\s*getattr\b")
_JUSTIFICATION_DOCSTRING = re.compile(r"\bgetattr\b")
_SCOPE_BOUNDARY = re.compile(r"^\s*(def |class )")
_MAX_LOOKBACK = 30

def check_content(self, file_path: str, content: str) -> list[Violation]:
if not file_path.endswith(".py"):
return []
normalised = os.path.normpath(file_path)
if not any(prefix in normalised for prefix in self._PROVIDER_PREFIXES):
return []

violations: list[Violation] = []
lines = content.splitlines()

# Pre-compute the docstring that covers each function/method body so
# we can check it cheaply per getattr line.
scope_docstrings = self._build_scope_docstring_map(lines)

for idx, line in enumerate(lines):
if not self._GETATTR_CALL.search(line):
continue
if self._is_justified(lines, idx, scope_docstrings):
continue
violations.append(
UnjustifiedGetAttrViolation(file_path, idx + 1, line.strip())
)
return violations

def _is_justified(
self,
lines: list[str],
idx: int,
scope_docstrings: dict[int, str],
) -> bool:
"""Return True if the getattr at *idx* has a visible justification."""
line = lines[idx]

# 1. Same-line comment.
if self._JUSTIFICATION_COMMENT.search(line):
return True

# 2. Scan backward within the same scope for a ``# getattr`` comment.
for back in range(1, self._MAX_LOOKBACK + 1):
prev_idx = idx - back
if prev_idx < 0:
break
prev = lines[prev_idx]
if self._JUSTIFICATION_COMMENT.search(prev):
return True
if self._SCOPE_BOUNDARY.search(prev):
break

# 3. Enclosing function/method docstring mentions ``getattr``.
for scope_start, docstring in scope_docstrings.items():
if scope_start < idx and self._JUSTIFICATION_DOCSTRING.search(docstring):
# Check that *idx* is inside this scope (no newer scope in
# between).
next_scope = min(
(s for s in scope_docstrings if s > scope_start),
default=len(lines),
)
if idx < next_scope:
return True

return False

@staticmethod
def _build_scope_docstring_map(lines: list[str]) -> dict[int, str]:
"""Map ``def``/``class`` line indices to their docstrings (if any)."""
scope_boundary = re.compile(r"^\s*(def |class )")
result: dict[int, str] = {}
for idx, line in enumerate(lines):
if not scope_boundary.search(line):
continue
# Look for a docstring on the next non-blank line.
doc_start = idx + 1
while doc_start < len(lines) and not lines[doc_start].strip():
doc_start += 1
if doc_start >= len(lines):
continue
first = lines[doc_start].strip()
if not (first.startswith('"""') or first.startswith("'''")):
continue
quote = first[:3]
if first.count(quote) >= 2:
# Single-line docstring.
result[idx] = first
else:
# Multi-line docstring — collect until closing quotes.
parts = [first]
for j in range(doc_start + 1, len(lines)):
parts.append(lines[j])
if quote in lines[j]:
break
result[idx] = "\n".join(parts)
return result


class QualityChecker:
"""Main quality checker that runs all checks."""

Expand All @@ -565,6 +695,7 @@ def __init__(self):
DocstringChecker(),
ImportChecker(),
CommentChecker(),
GetAttrChecker(),
]
self.gitignore_spec = self._load_gitignore()

Expand Down Expand Up @@ -781,6 +912,8 @@ def main():
category = "Unused imports"
elif "Commented-out code" in v.message:
category = "Commented-out code"
elif "getattr should only" in v.message:
category = "Unjustified getattr"
else:
category = "Other issues"

Expand Down
52 changes: 26 additions & 26 deletions docs/root/architecture/clean_architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,13 @@ Clean Architecture organizes code into layers with clear dependency rules:
The domain layer contains the core business logic and has no external dependencies.

#### Location
- `src/domain/`
- `src/orb/domain/`

#### Components

**Entities (Aggregates)**
```python
# src/domain/template/aggregate.py
# src/orb/domain/template/template_aggregate.py
class Template(BaseModel):
"""Template configuration representing VM template."""
template_id: str
Expand All @@ -38,7 +38,7 @@ class Template(BaseModel):

**Value Objects**
```python
# src/domain/machine/machine_status.py
# src/orb/domain/machine/value_objects.py
class MachineStatus(Enum):
"""Machine status value object."""
PENDING = "pending"
Expand All @@ -48,7 +48,7 @@ class MachineStatus(Enum):

**Domain Services**
```python
# src/domain/template/ami_resolver.py
# src/orb/providers/aws/domain/services/ami_resolver.py
class AMIResolver:
"""Domain service for AMI resolution logic."""

Expand All @@ -58,7 +58,7 @@ class AMIResolver:

**Repository Interfaces**
```python
# src/domain/template/repository.py
# src/orb/domain/template/repository.py
class TemplateRepository(ABC):
"""Abstract repository interface."""

Expand All @@ -78,27 +78,27 @@ class TemplateRepository(ABC):
The application layer orchestrates domain objects and implements use cases.

#### Location
- `src/application/`
- `src/orb/application/`

#### Components

**Application Services**
```python
# src/application/service.py
# src/orb/application/services/provisioning_orchestration_service.py
@injectable
class ApplicationService:
"""Main application orchestrator."""

def __init__(self,
command_bus: CommandBus,
query_bus: QueryBus,
provider_context: ProviderContext):
provider_selection_port: ProviderSelectionPort):
# Dependencies injected, not created
```

**Command Handlers (CQRS)**
```python
# src/application/commands/template_handlers.py
# src/orb/application/commands/template_handlers.py
class GetTemplatesHandler:
"""Handle template retrieval commands."""

Expand All @@ -108,7 +108,7 @@ class GetTemplatesHandler:

**Query Handlers (CQRS)**
```python
# src/application/queries/handlers.py
# src/orb/application/queries/template_query_handlers.py
class TemplateQueryHandler:
"""Handle template queries."""

Expand All @@ -118,7 +118,7 @@ class TemplateQueryHandler:

**Data Transfer Objects**
```python
# src/application/dto/commands.py
# src/orb/application/dto/commands.py
class CreateRequestCommand:
"""Command for creating requests."""
template_id: str
Expand All @@ -136,14 +136,14 @@ class CreateRequestCommand:
The infrastructure layer implements external concerns and technical details.

#### Location
- `src/infrastructure/`
- `src/providers/`
- `src/orb/infrastructure/`
- `src/orb/providers/`

#### Components

**Repository Implementations**
```python
# src/infrastructure/storage/repositories/template_repository.py
# src/orb/infrastructure/storage/repositories/template_repository.py
class TemplateRepositoryImpl(TemplateRepository):
"""Template repository implementation."""

Expand All @@ -153,18 +153,18 @@ class TemplateRepositoryImpl(TemplateRepository):

**External Service Adapters**
```python
# src/providers/aws/managers/aws_instance_manager.py
# src/orb/providers/aws/infrastructure/aws_client.py
@injectable
class AWSInstanceManager:
"""AWS-specific instance management."""
class AWSClient:
"""Provider-specific resource management."""

def __init__(self, aws_client: AWSClient, logger: LoggingPort):
def __init__(self, config: ConfigurationPort, logger: LoggingPort):
# Infrastructure dependencies
```

**Dependency Injection Container**
```python
# src/infrastructure/di/container.py
# src/orb/infrastructure/di/container.py
class DIContainer:
"""Dependency injection container."""

Expand All @@ -174,7 +174,7 @@ class DIContainer:

**Configuration Management**
```python
# src/infrastructure/config/manager.py
# src/orb/config/managers/configuration_manager.py
class ConfigurationManager:
"""Configuration management implementation."""
```
Expand All @@ -190,15 +190,15 @@ class ConfigurationManager:
The interface layer provides external access points to the system.

#### Location
- `src/interface/`
- `src/api/`
- `src/cli/`
- `src/orb/interface/`
- `src/orb/api/`
- `src/orb/cli/`

#### Components

**CLI Interface**
```python
# src/cli/main.py
# src/orb/cli/main.py
def main():
"""CLI entry point."""
# Parse arguments
Expand All @@ -208,7 +208,7 @@ def main():

**REST API Interface**
```python
# src/api/routers/templates.py
# src/orb/api/routers/templates.py
@router.get("/templates")
async def get_templates():
"""REST API endpoint."""
Expand All @@ -219,7 +219,7 @@ async def get_templates():

**Interface Command Handlers**
```python
# src/interface/template_command_handlers.py
# src/orb/interface/template_command_handlers.py
class TemplateCommandHandler:
"""Handle CLI template commands."""

Expand Down
Loading
Loading