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
21 changes: 14 additions & 7 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
# Default target
all: test

PYTHON ?= python

help:
@echo "Multi-Language Build System for cyborg-data"
@echo ""
Expand All @@ -22,14 +24,21 @@ help:
@echo ""
@echo "Go-specific targets:"
@echo " make go-test - Run Go tests"
@echo " make go-test-with-gcs - Run Go tests with GCS support"
@echo " make go-lint - Run Go linter"
@echo " make go-build - Build Go examples"
@echo " make go-bench - Run Go benchmarks"
@echo ""
@echo "Python-specific targets:"
@echo " make python-test - Run Python tests"
@echo " make python-lint - Run Python linter"
@echo " make python-typing - Run Python type checker (pyright strict)"
@echo " make python-typing - Run Python type checker (mypy strict)"
@echo " make python-format - Format Python code with ruff"
@echo " make python-build - Build Python package"
@echo ""
@echo "Validation targets:"
@echo " make validate-parity - Validate API parity between Go and Python"
@echo " make docs - Build documentation"

# Combined targets
test: go-test python-test
Expand Down Expand Up @@ -79,8 +88,8 @@ python-lint:
cd python && ruff check .

python-typing:
@echo "Running Python type checker..."
cd python && pyright
@echo "Running Python type checker (mypy strict)..."
cd python && $(PYTHON) -m mypy orgdatacore

python-format:
@echo "Formatting Python code..."
Expand All @@ -94,12 +103,10 @@ python-clean:
@echo "Cleaning Python build artifacts..."
cd python && rm -rf dist/ build/ *.egg-info .pytest_cache .mypy_cache .ruff_cache __pycache__

# Validation targets
validate-parity:
@echo "Validating API parity between Go and Python..."
@./scripts/validate-api-parity.sh || echo "Parity validation script not yet implemented"
@./scripts/validate-api-parity.sh

# Documentation targets
docs:
docs: #todo
@echo "Building documentation..."
@echo "Documentation build not yet configured"
3 changes: 3 additions & 0 deletions go/interface.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,11 @@ type ServiceInterface interface {
GetUserOrganizations(slackUserID string) []OrgInfo

GetVersion() DataVersion
GetDataAge() time.Duration
IsDataStale(maxAge time.Duration) bool
LoadFromDataSource(ctx context.Context, source DataSource) error
StartDataSourceWatcher(ctx context.Context, source DataSource) error
StopWatcher()

GetAllEmployeeUIDs() []string
GetAllTeamNames() []string
Expand Down
87 changes: 76 additions & 11 deletions go/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ type Service struct {
version DataVersion
logger *slog.Logger
watcherRunning bool
watcherCancel context.CancelFunc
}

func NewService(opts ...ServiceOption) *Service {
Expand Down Expand Up @@ -67,17 +68,23 @@ func (s *Service) StartDataSourceWatcher(ctx context.Context, source DataSource)
return ErrWatcherAlreadyRunning
}
s.watcherRunning = true

// Create a cancellable context so StopWatcher can terminate the watcher
watchCtx, cancel := context.WithCancel(ctx)
s.watcherCancel = cancel
s.mu.Unlock()

if err := s.LoadFromDataSource(ctx, source); err != nil {
if err := s.LoadFromDataSource(watchCtx, source); err != nil {
s.mu.Lock()
s.watcherRunning = false
s.watcherCancel = nil
s.mu.Unlock()
cancel() // Clean up the context
return err
}

err := source.Watch(ctx, func() error {
if err := s.LoadFromDataSource(ctx, source); err != nil {
err := source.Watch(watchCtx, func() error {
if err := s.LoadFromDataSource(watchCtx, source); err != nil {
s.logger.Error("failed to reload data", "source", source.String(), "error", err)
return err
}
Expand All @@ -87,18 +94,25 @@ func (s *Service) StartDataSourceWatcher(ctx context.Context, source DataSource)
// Clear watcher state when Watch exits (context cancelled, error, etc.)
s.mu.Lock()
s.watcherRunning = false
s.watcherCancel = nil
s.mu.Unlock()

return err
}

// StopWatcher marks the watcher as stopped. Note that this only updates the
// Service's internal state - actual watcher termination depends on the
// DataSource implementation respecting context cancellation.
// StopWatcher stops the running watcher by cancelling its context.
// This signals the DataSource.Watch method to exit. The method is safe to call
// even if no watcher is running.
func (s *Service) StopWatcher() {
s.mu.Lock()
defer s.mu.Unlock()
cancel := s.watcherCancel
s.watcherCancel = nil
s.watcherRunning = false
s.mu.Unlock()

if cancel != nil {
cancel()
}
}

func (s *Service) GetVersion() DataVersion {
Expand All @@ -107,6 +121,30 @@ func (s *Service) GetVersion() DataVersion {
return s.version
}

// GetDataAge returns the duration since data was last loaded.
// Returns 0 if no data has been loaded.
func (s *Service) GetDataAge() time.Duration {
s.mu.RLock()
defer s.mu.RUnlock()

if s.version.LoadTime.IsZero() {
return 0
}
return time.Since(s.version.LoadTime)
}

// IsDataStale returns true if data is older than maxAge, or if no data is loaded.
// Use this in health checks to detect stale data from failed reloads.
func (s *Service) IsDataStale(maxAge time.Duration) bool {
s.mu.RLock()
defer s.mu.RUnlock()

if s.data == nil || s.version.LoadTime.IsZero() {
return true
}
return time.Since(s.version.LoadTime) > maxAge
}

func (s *Service) GetEmployeeByUID(uid string) *Employee {
s.mu.RLock()
defer s.mu.RUnlock()
Expand Down Expand Up @@ -244,6 +282,11 @@ func (s *Service) GetTeamsForUID(uid string) []string {
s.mu.RLock()
defer s.mu.RUnlock()

return s.getTeamsForUID(uid)
}

// getTeamsForUID is the internal version that assumes the lock is held.
func (s *Service) getTeamsForUID(uid string) []string {
if s.data == nil || s.data.Indexes.Membership.MembershipIndex == nil {
return []string{}
}
Expand All @@ -258,11 +301,14 @@ func (s *Service) GetTeamsForUID(uid string) []string {
}

func (s *Service) GetTeamsForSlackID(slackID string) []string {
s.mu.RLock()
defer s.mu.RUnlock()

uid := s.getUIDFromSlackID(slackID)
if uid == "" {
return []string{}
}
return s.GetTeamsForUID(uid)
return s.getTeamsForUID(uid)
}

func (s *Service) GetTeamMembers(teamName string) []Employee {
Expand All @@ -288,7 +334,15 @@ func (s *Service) GetTeamMembers(teamName string) []Employee {
}

func (s *Service) IsEmployeeInTeam(uid string, teamName string) bool {
for _, team := range s.GetTeamsForUID(uid) {
s.mu.RLock()
defer s.mu.RUnlock()

return s.isEmployeeInTeam(uid, teamName)
}

// isEmployeeInTeam is the internal version that assumes the lock is held.
func (s *Service) isEmployeeInTeam(uid string, teamName string) bool {
for _, team := range s.getTeamsForUID(uid) {
if team == teamName {
return true
}
Expand All @@ -297,17 +351,25 @@ func (s *Service) IsEmployeeInTeam(uid string, teamName string) bool {
}

func (s *Service) IsSlackUserInTeam(slackID string, teamName string) bool {
s.mu.RLock()
defer s.mu.RUnlock()

uid := s.getUIDFromSlackID(slackID)
if uid == "" {
return false
}
return s.IsEmployeeInTeam(uid, teamName)
return s.isEmployeeInTeam(uid, teamName)
}

func (s *Service) IsEmployeeInOrg(uid string, orgName string) bool {
s.mu.RLock()
defer s.mu.RUnlock()

return s.isEmployeeInOrg(uid, orgName)
}

// isEmployeeInOrg is the internal version that assumes the lock is held.
func (s *Service) isEmployeeInOrg(uid string, orgName string) bool {
if s.data == nil || s.data.Indexes.Membership.MembershipIndex == nil {
return false
}
Expand All @@ -329,11 +391,14 @@ func (s *Service) IsEmployeeInOrg(uid string, orgName string) bool {
}

func (s *Service) IsSlackUserInOrg(slackID string, orgName string) bool {
s.mu.RLock()
defer s.mu.RUnlock()

uid := s.getUIDFromSlackID(slackID)
if uid == "" {
return false
}
return s.IsEmployeeInOrg(uid, orgName)
return s.isEmployeeInOrg(uid, orgName)
}

func (s *Service) GetUserOrganizations(slackUserID string) []OrgInfo {
Expand Down
14 changes: 14 additions & 0 deletions parity/discovery/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
"""Discovery module for API parity checking."""

from .go_parser import parse_go_interface, GoMethod
from .python_introspector import introspect_python_service, PythonMethod
from .name_mapping import go_to_python, python_to_go

__all__ = [
"parse_go_interface",
"GoMethod",
"introspect_python_service",
"PythonMethod",
"go_to_python",
"python_to_go",
]
116 changes: 116 additions & 0 deletions parity/discovery/go_parser.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
"""Parse Go interface.go to extract method signatures."""

import re
from dataclasses import dataclass
from pathlib import Path


@dataclass
class GoMethod:
"""Represents a Go method signature."""

name: str
params: list[tuple[str, str]] # [(param_name, param_type), ...]
return_type: str


def parse_go_interface(interface_path: Path) -> list[GoMethod]:
"""Parse Go interface.go and extract public methods from ServiceInterface.

Args:
interface_path: Path to go/interface.go

Returns:
List of GoMethod objects representing the interface methods.
"""
content = interface_path.read_text()

# Find the ServiceInterface block
interface_match = re.search(
r'type\s+ServiceInterface\s+interface\s*\{([^}]+)\}',
content,
re.DOTALL
)

if not interface_match:
raise ValueError("Could not find ServiceInterface in interface.go")

interface_body = interface_match.group(1)

methods = []
method_pattern = re.compile(
r'^\s*(\w+)\s*\(([^)]*)\)\s*(.+?)\s*$',
re.MULTILINE
)

for match in method_pattern.finditer(interface_body):
name = match.group(1)
params_str = match.group(2).strip()
return_type = match.group(3).strip()

# Skip comments
if name.startswith('//'):
continue

params = parse_params(params_str)
methods.append(GoMethod(name=name, params=params, return_type=return_type))

return methods


def parse_params(params_str: str) -> list[tuple[str, str]]:
"""Parse Go parameter string into list of (name, type) tuples.

Examples:
"uid string" -> [("uid", "string")]
"uid string, teamName string" -> [("uid", "string"), ("teamName", "string")]
"" -> []
"ctx context.Context, source DataSource" -> [("ctx", "context.Context"), ("source", "DataSource")]
"""
if not params_str:
return []

params = []
for param in params_str.split(','):
param = param.strip()
if not param:
continue

# Split on last space to handle types like "context.Context"
parts = param.rsplit(' ', 1)
if len(parts) == 2:
params.append((parts[0].strip(), parts[1].strip()))
else:
# Handle case where type is implied from previous param
params.append(("", parts[0].strip()))

return params


def get_return_type_category(return_type: str) -> str:
"""Categorize Go return type for serialization.

Args:
return_type: Go return type string like "*Employee" or "[]string"

Returns:
Category string like "entity_pointer", "string_list", "bool"
"""
return_type = return_type.strip()

if return_type == "bool":
return "bool"
if return_type == "error":
return "error"
if return_type.startswith("[]string"):
return "string_list"
if return_type.startswith("[]"):
return "entity_list"
if return_type.startswith("*"):
return "entity_pointer"
if return_type == "time.Duration":
return "duration"
if return_type == "DataVersion":
return "data_version"

return "unknown"
Loading