Refactor community command: consolidate login/logout under community namespace

- Move login/logout from standalone commands to tito community subcommands
- Reduce community.py from 860 to 264 lines by removing placeholder profile code
- Delegate login/logout to existing LoginCommand/LogoutCommand (no auth code changes)
- Maintain leaderboard, compete, and submit functionality
- Apply consistent Rich formatting:  emoji, [cyan] for actions, [green] for success
- Update main.py help text to show new command structure

Commands:
  tito community login       - OAuth login via browser
  tito community logout      - Clear credentials
  tito community leaderboard - Open leaderboard
  tito community compete     - Open competitions
  tito community submit      - Validate/submit benchmarks

All auth/submission code unchanged. Style consistent with TinyTorch CLI conventions.
This commit is contained in:
Vijay Janapa Reddi
2025-12-01 07:53:43 -05:00
parent c3e37567de
commit 7d3e302672
2 changed files with 31 additions and 630 deletions

View File

@@ -1,38 +1,32 @@
"""
Tiny🔥Torch Community Commands
Join, update, and manage your community profile for the global builder map.
Login, logout, and connect with the TinyTorch community.
"""
import json
import os
import webbrowser
import urllib.parse
from argparse import ArgumentParser, Namespace
from datetime import datetime
from pathlib import Path
from typing import Dict, List, Optional, Any
from rich.panel import Panel
from rich.table import Table
from rich.prompt import Prompt, Confirm
from rich.console import Console
from .base import BaseCommand
from ..core.exceptions import TinyTorchCLIError
from .login import LoginCommand, LogoutCommand
class CommunityCommand(BaseCommand):
"""Community commands - join, update, leave, and manage your profile."""
"""Community commands - login, logout, leaderboard, and benchmarks."""
@property
def name(self) -> str:
return "community"
@property
def description(self) -> str:
return "Join the global community - connect with builders worldwide"
def add_arguments(self, parser: ArgumentParser) -> None:
"""Add community subcommands."""
subparsers = parser.add_subparsers(
@@ -40,77 +34,20 @@ class CommunityCommand(BaseCommand):
help='Community operations',
metavar='COMMAND'
)
# Join command
join_parser = subparsers.add_parser(
'join',
help='Join the TinyTorch community'
# Login command (delegates to LoginCommand)
login_parser = subparsers.add_parser(
'login',
help='Log in to TinyTorch via web browser'
)
join_parser.add_argument(
'--country',
help='Your country (optional, auto-detected if possible)'
)
join_parser.add_argument(
'--institution',
help='Your institution/school (optional)'
)
join_parser.add_argument(
'--course-type',
choices=['university', 'bootcamp', 'self-paced', 'other'],
help='Course type (optional)'
)
join_parser.add_argument(
'--experience',
choices=['beginner', 'intermediate', 'advanced', 'expert'],
help='Experience level (optional)'
)
# Update command
update_parser = subparsers.add_parser(
'update',
help='Update your community profile'
)
update_parser.add_argument(
'--country',
help='Update country'
)
update_parser.add_argument(
'--institution',
help='Update institution'
)
update_parser.add_argument(
'--course-type',
choices=['university', 'bootcamp', 'self-paced', 'other'],
help='Update course type'
)
update_parser.add_argument(
'--experience',
choices=['beginner', 'intermediate', 'advanced', 'expert'],
help='Update experience level'
)
# Leave command
leave_parser = subparsers.add_parser(
'leave',
help='Leave the community (removes your profile)'
)
leave_parser.add_argument(
'--force',
action='store_true',
help='Skip confirmation'
)
# Stats command
stats_parser = subparsers.add_parser(
'stats',
help='View community statistics'
)
# Profile command
profile_parser = subparsers.add_parser(
'profile',
help='View your community profile'
LoginCommand(self.config).add_arguments(login_parser)
# Logout command (delegates to LogoutCommand)
logout_parser = subparsers.add_parser(
'logout',
help='Log out of TinyTorch'
)
LogoutCommand(self.config).add_arguments(logout_parser)
# Leaderboard command (opens browser)
leaderboard_parser = subparsers.add_parser(
@@ -133,23 +70,17 @@ class CommunityCommand(BaseCommand):
'submission_file',
help='Path to submission JSON file (e.g., submission.json)'
)
def run(self, args: Namespace) -> int:
"""Execute community command."""
if not args.community_command:
self.console.print("[yellow]Please specify a community command: join, leaderboard, compete, profile[/yellow]")
self.console.print("[yellow]Please specify a community command: login, logout, leaderboard, compete, submit[/yellow]")
return 1
if args.community_command == 'join':
return self._join_community(args)
elif args.community_command == 'update':
return self._update_profile(args)
elif args.community_command == 'leave':
return self._leave_community(args)
elif args.community_command == 'stats':
return self._show_stats(args)
elif args.community_command == 'profile':
return self._show_profile(args)
if args.community_command == 'login':
return LoginCommand(self.config).run(args)
elif args.community_command == 'logout':
return LogoutCommand(self.config).run(args)
elif args.community_command == 'leaderboard':
return self._open_leaderboard(args)
elif args.community_command == 'compete':
@@ -157,535 +88,8 @@ class CommunityCommand(BaseCommand):
elif args.community_command == 'submit':
return self._submit_benchmark(args)
else:
self.console.print(f"[red]Unknown community command: {args.community_command}[/red]")
self.console.print(f"[red]Unknown community command: {args.community_command}[/red]")
return 1
def _join_community(self, args: Namespace) -> int:
"""Join the TinyTorch community - GitHub-first flow."""
console = self.console
# Check if already joined
profile = self._get_profile()
if profile:
github_username = profile.get("github_username")
profile_url = profile.get("profile_url", "https://tinytorch.ai/community")
console.print(Panel(
f"[yellow]⚠️ You're already in the community![/yellow]\n\n"
f"GitHub: [cyan]@{github_username}[/cyan]\n"
f"Profile: [cyan]{profile_url}[/cyan]\n\n"
f"Update online: [cyan]{profile_url}[/cyan]\n"
f"View profile: [cyan]tito community profile[/cyan]",
title="Already Joined",
border_style="yellow"
))
return 0
console.print(Panel(
"[bold cyan]🌍 Join the TinyTorch Community[/bold cyan]\n\n"
"Connect with ML systems builders worldwide!\n"
"We'll ask 3 quick questions, then open your browser to complete your profile.",
title="Welcome",
border_style="cyan"
))
console.print("\n[dim]Your data:[/dim]")
console.print(" • Stored locally in [cyan].tinytorch/community.json[/cyan]")
console.print(" • GitHub username for authentication")
console.print(" • Basic info shared with community (country, institution)")
console.print(" • Full profile completed on tinytorch.ai\n")
# Question 1: GitHub username (REQUIRED)
console.print("[bold]Question 1/3[/bold]")
github_username = Prompt.ask(
"[cyan]GitHub username[/cyan] (required for authentication)",
default=""
).strip()
if not github_username:
console.print("[red]❌ GitHub username is required to join the community[/red]")
console.print("[dim]Your GitHub username is used to:\n"
" • Authenticate your profile\n "
" • Link to your projects\n"
" • Connect with other builders[/dim]")
return 1
# Question 2: Country (optional, auto-detect)
console.print("\n[bold]Question 2/3[/bold]")
country = self._detect_country()
if country:
console.print(f"[dim]Auto-detected: {country}[/dim]")
country = Prompt.ask(
"[cyan]Country[/cyan] (for community map, optional)",
default=country or "",
show_default=False
).strip()
# Question 3: Institution (optional)
console.print("\n[bold]Question 3/3[/bold]")
institution = Prompt.ask(
"[cyan]Institution/University[/cyan] (optional)",
default="",
show_default=False
).strip()
# Create local profile
profile = {
"github_username": github_username,
"joined_at": datetime.now().isoformat(),
"country": country or None,
"institution": institution or None,
"profile_url": f"https://tinytorch.ai/community/{github_username}",
"last_synced": None
}
# Save profile locally
self._save_profile(profile)
# Build URL with pre-filled params
base_url = "https://tinytorch.ai/community/join"
params = {
"github": github_username,
}
if country:
params["country"] = country
if institution:
params["institution"] = institution
signup_url = f"{base_url}?{urllib.parse.urlencode(params)}"
# Show success and open browser
console.print("\n")
console.print(Panel(
f"[bold green]✅ Local profile created![/bold green]\n\n"
f"👤 GitHub: [cyan]@{github_username}[/cyan]\n"
f"📍 Country: {country or '[dim]Not specified[/dim]'}\n"
f"🏫 Institution: {institution or '[dim]Not specified[/dim]'}\n\n"
f"[bold cyan]🌐 Opening browser to complete your profile...[/bold cyan]\n"
f"[dim]URL: {signup_url}[/dim]\n\n"
f"Complete your profile online to:\n"
f" • Authenticate with GitHub OAuth\n"
f" • Add bio, interests, and social links\n"
f" • Join the global community map\n"
f" • Connect with other builders",
title="Almost There!",
border_style="green"
))
# Open browser
try:
webbrowser.open(signup_url)
console.print("\n[green]✓[/green] Browser opened! Complete your profile there.")
except Exception as e:
console.print(f"\n[yellow]⚠️ Could not open browser automatically[/yellow]")
console.print(f"[dim]Please visit: {signup_url}[/dim]")
console.print(f"\n[dim]💡 View profile later: [cyan]tito community profile[/cyan][/dim]")
return 0
def _update_profile(self, args: Namespace) -> int:
"""Update community profile."""
console = self.console
# Get existing profile
profile = self._get_profile()
if not profile:
console.print(Panel(
"[yellow]⚠️ You're not in the community yet.[/yellow]\n\n"
"Join first: [cyan]tito community join[/cyan]",
title="Not Joined",
border_style="yellow"
))
return 1
console.print(Panel(
"[bold cyan]📝 Update Your Community Profile[/bold cyan]",
title="Update Profile",
border_style="cyan"
))
# Update fields
updated = False
if args.country:
profile["location"]["country"] = args.country
updated = True
console.print(f"[green]✅ Updated country: {args.country}[/green]")
if args.institution:
profile["institution"]["name"] = args.institution
updated = True
console.print(f"[green]✅ Updated institution: {args.institution}[/green]")
if args.course_type:
profile["context"]["course_type"] = args.course_type
updated = True
console.print(f"[green]✅ Updated course type: {args.course_type}[/green]")
if args.experience:
profile["context"]["experience_level"] = args.experience
updated = True
console.print(f"[green]✅ Updated experience level: {args.experience}[/green]")
# If no args provided, do interactive update
if not updated:
console.print("\n[cyan]Interactive update (press Enter to keep current value):[/cyan]\n")
# Country
current_country = profile["location"].get("country", "")
new_country = Prompt.ask(
f"[cyan]Country[/cyan]",
default=current_country or "",
show_default=bool(current_country)
)
if new_country != current_country:
profile["location"]["country"] = new_country or None
updated = True
# Institution
current_institution = profile["institution"].get("name", "")
new_institution = Prompt.ask(
f"[cyan]Institution[/cyan]",
default=current_institution or "",
show_default=bool(current_institution)
)
if new_institution != current_institution:
profile["institution"]["name"] = new_institution or None
updated = True
# Update progress if available
self._update_progress(profile)
# Save updated profile
if updated:
profile["updated_at"] = datetime.now().isoformat()
self._save_profile(profile)
console.print("\n[green]✅ Profile updated successfully![/green]")
else:
console.print("\n[yellow]No changes made.[/yellow]")
return 0
def _leave_community(self, args: Namespace) -> int:
"""Leave the community."""
console = self.console
# Get existing profile
profile = self._get_profile()
if not profile:
console.print(Panel(
"[yellow]⚠️ You're not in the community.[/yellow]",
title="Not Joined",
border_style="yellow"
))
return 0
# Confirm
if not args.force:
console.print(Panel(
"[yellow]⚠️ Warning: This will remove your community profile[/yellow]\n\n"
"This action cannot be undone.\n"
"Your benchmark submissions will remain, but your profile will be removed.",
title="Leave Community",
border_style="yellow"
))
confirm = Confirm.ask("\n[red]Are you sure you want to leave?[/red]", default=False)
if not confirm:
console.print("[cyan]Cancelled.[/cyan]")
return 0
# Remove profile
profile_file = self._get_profile_file()
if profile_file.exists():
profile_file.unlink()
# Stub: Notify website of leave
self._notify_website_leave(profile.get("anonymous_id") if profile else None)
console.print(Panel(
"[green]✅ You've left the community.[/green]\n\n"
"You can rejoin anytime with: [cyan]tito community join[/cyan]",
title="Left Community",
border_style="green"
))
return 0
def _show_stats(self, args: Namespace) -> int:
"""Show community statistics."""
console = self.console
# For now, show local stats
# In production, this would fetch from a server
profile = self._get_profile()
console.print(Panel(
"[bold cyan]🌍 TinyTorch Community Stats[/bold cyan]\n\n"
"[dim]Note: Full community stats require server connection.[/dim]\n"
"This shows your local information.",
title="Community Stats",
border_style="cyan"
))
if profile:
console.print(f"\n[cyan]Your Profile:[/cyan]")
console.print(f" • Country: {profile['location'].get('country', 'Not specified')}")
console.print(f" • Institution: {profile['institution'].get('name', 'Not specified')}")
console.print(f" • Course Type: {profile['context'].get('course_type', 'Not specified')}")
console.print(f" • Experience: {profile['context'].get('experience_level', 'Not specified')}")
console.print(f" • Cohort: {profile['context'].get('cohort', 'Not specified')}")
else:
console.print("\n[yellow]You're not in the community yet.[/yellow]")
console.print("Join with: [cyan]tito community join[/cyan]")
return 0
def _show_profile(self, args: Namespace) -> int:
"""Show user's community profile."""
console = self.console
profile = self._get_profile()
if not profile:
console.print(Panel(
"[yellow]⚠️ You're not in the community yet.[/yellow]\n\n"
"Join with: [cyan]tito community join[/cyan]",
title="Not Joined",
border_style="yellow"
))
return 1
# Display profile
profile_table = Table(title="Your Community Profile", show_header=False, box=None)
profile_table.add_column("Field", style="cyan", width=20)
profile_table.add_column("Value", style="green")
profile_table.add_row("Anonymous ID", profile.get("anonymous_id", "N/A"))
profile_table.add_row("Joined", self._format_date(profile.get("joined_at")))
profile_table.add_row("Country", profile["location"].get("country", "Not specified"))
profile_table.add_row("Institution", profile["institution"].get("name", "Not specified"))
profile_table.add_row("Course Type", profile["context"].get("course_type", "Not specified"))
profile_table.add_row("Experience", profile["context"].get("experience_level", "Not specified"))
profile_table.add_row("Cohort", profile["context"].get("cohort", "Not specified"))
progress = profile.get("progress", {})
profile_table.add_row("", "")
profile_table.add_row("[bold]Progress[/bold]", "")
profile_table.add_row("Setup Verified", "" if progress.get("setup_verified") else "")
profile_table.add_row("Milestones Passed", str(progress.get("milestones_passed", 0)))
profile_table.add_row("Modules Completed", str(progress.get("modules_completed", 0)))
capstone_score = progress.get("capstone_score")
profile_table.add_row("Capstone Score", f"{capstone_score}/100" if capstone_score else "Not completed")
console.print("\n")
console.print(profile_table)
return 0
def _get_profile(self) -> Optional[Dict[str, Any]]:
"""Get user's community profile."""
profile_file = self._get_profile_file()
if profile_file.exists():
try:
with open(profile_file, 'r') as f:
return json.load(f)
except Exception:
return None
return None
def _save_profile(self, profile: Dict[str, Any]) -> None:
"""Save user's community profile."""
profile_file = self._get_profile_file()
profile_file.parent.mkdir(parents=True, exist_ok=True)
with open(profile_file, 'w') as f:
json.dump(profile, f, indent=2)
# Stub: Sync with website if configured
self._sync_profile_to_website(profile)
def _get_profile_file(self) -> Path:
"""Get path to profile file (project-local)."""
return self.config.project_root / ".tinytorch" / "community" / "profile.json"
def _get_config(self) -> Dict[str, Any]:
"""Get community configuration."""
config_file = self.config.project_root / ".tinytorch" / "config.json"
default_config = {
"website": {
"base_url": "https://tinytorch.ai",
"community_map_url": "https://tinytorch.ai/community",
"api_url": None, # Set when API is available
"enabled": False # Set to True when website integration is ready
},
"local": {
"enabled": True, # Always use local storage
"auto_sync": False # Auto-sync to website when enabled
}
}
if config_file.exists():
try:
with open(config_file, 'r') as f:
user_config = json.load(f)
# Merge with defaults
default_config.update(user_config)
return default_config
except Exception:
pass
# Create default config if it doesn't exist
config_file.parent.mkdir(parents=True, exist_ok=True)
with open(config_file, 'w') as f:
json.dump(default_config, f, indent=2)
return default_config
def _sync_profile_to_website(self, profile: Dict[str, Any]) -> None:
"""Stub: Sync profile to website (local for now, website integration later)."""
config = self._get_config()
if not config.get("website", {}).get("enabled", False):
# Website integration not enabled, just store locally
return
# Stub for future website API integration
api_url = config.get("website", {}).get("api_url")
if api_url:
# TODO: Implement API call when website is ready
# Example:
# import requests
# response = requests.post(f"{api_url}/api/community/profile", json=profile)
# response.raise_for_status()
pass
def _detect_country(self) -> Optional[str]:
"""Try to detect country from system."""
# Try timezone first
try:
import time
tz = time.tzname[0] if time.daylight == 0 else time.tzname[1]
# This is a simple heuristic - could be improved
return None # Don't auto-detect for privacy
except Exception:
return None
def _determine_cohort(self) -> str:
"""Determine cohort based on current date."""
now = datetime.now()
month = now.month
if month in [9, 10, 11, 12]:
return f"Fall {now.year}"
elif month in [1, 2, 3, 4, 5]:
return f"Spring {now.year}"
else:
return f"Summer {now.year}"
def _update_progress(self, profile: Dict[str, Any]) -> None:
"""Update progress information from local data."""
# Check milestone progress
milestone_file = Path(".tito") / "milestones.json"
if milestone_file.exists():
try:
with open(milestone_file, 'r') as f:
milestones_data = json.load(f)
completed = milestones_data.get("completed_milestones", [])
profile["progress"]["milestones_passed"] = len(completed)
except Exception:
pass
# Check module progress
progress_file = Path(".tito") / "progress.json"
if progress_file.exists():
try:
with open(progress_file, 'r') as f:
progress_data = json.load(f)
completed = progress_data.get("completed_modules", [])
profile["progress"]["modules_completed"] = len(completed)
except Exception:
pass
# Check capstone score
benchmark_dir = Path(".tito") / "benchmarks"
if benchmark_dir.exists():
# Find latest capstone benchmark
capstone_files = sorted(benchmark_dir.glob("capstone_*.json"), reverse=True)
if capstone_files:
try:
with open(capstone_files[0], 'r') as f:
capstone_data = json.load(f)
profile["progress"]["capstone_score"] = capstone_data.get("overall_score")
except Exception:
pass
def _format_date(self, date_str: Optional[str]) -> str:
"""Format ISO date string."""
if not date_str:
return "N/A"
try:
dt = datetime.fromisoformat(date_str.replace('Z', '+00:00'))
return dt.strftime("%Y-%m-%d %H:%M")
except Exception:
return date_str
def _notify_website_join(self, profile: Dict[str, Any]) -> None:
"""Stub: Notify website when user joins (local for now, website integration later)."""
config = self._get_config()
if not config.get("website", {}).get("enabled", False):
# Website integration not enabled
return
api_url = config.get("website", {}).get("api_url")
if api_url:
# TODO: Implement API call when website is ready
# Example:
# import requests
# try:
# response = requests.post(
# f"{api_url}/api/community/join",
# json=profile,
# timeout=10, # 10 second timeout
# headers={"Content-Type": "application/json"}
# )
# response.raise_for_status()
# except requests.Timeout:
# self.console.print("[dim]Note: Website sync timed out. Your data is saved locally.[/dim]")
# except requests.RequestException as e:
# # Log error but don't fail the command
# self.console.print(f"[dim]Note: Could not sync with website: {e}[/dim]")
# self.console.print("[dim]Your data is saved locally and can be synced later.[/dim]")
pass
def _notify_website_leave(self, anonymous_id: Optional[str]) -> None:
"""Stub: Notify website when user leaves (local for now, website integration later)."""
config = self._get_config()
if not config.get("website", {}).get("enabled", False):
# Website integration not enabled
return
api_url = config.get("website", {}).get("api_url")
if api_url and anonymous_id:
# TODO: Implement API call when website is ready
# Example:
# import requests
# try:
# response = requests.post(
# f"{api_url}/api/community/leave",
# json={"anonymous_id": anonymous_id},
# timeout=10, # 10 second timeout
# headers={"Content-Type": "application/json"}
# )
# response.raise_for_status()
# except requests.Timeout:
# self.console.print("[dim]Note: Website sync timed out. Profile removed locally.[/dim]")
# except requests.RequestException as e:
# # Log error but don't fail the command
# self.console.print(f"[dim]Note: Could not sync with website: {e}[/dim]")
# self.console.print("[dim]Profile removed locally.[/dim]")
pass
def _open_leaderboard(self, args: Namespace) -> int:
"""Open community leaderboard in browser."""
@@ -693,10 +97,10 @@ class CommunityCommand(BaseCommand):
leaderboard_url = "https://tinytorch.ai/community/leaderboard"
self.console.print(f"[blue]🏆 Opening leaderboard...[/blue]")
self.console.print(f"[cyan]🏆 Opening leaderboard...[/cyan]")
try:
webbrowser.open(leaderboard_url)
self.console.print(f"[green][/green] Browser opened: [cyan]{leaderboard_url}[/cyan]")
self.console.print(f"[green][/green] Browser opened: [cyan]{leaderboard_url}[/cyan]")
except Exception as e:
self.console.print(f"[yellow]⚠️ Could not open browser automatically[/yellow]")
self.console.print(f"[dim]Please visit: {leaderboard_url}[/dim]")
@@ -709,10 +113,10 @@ class CommunityCommand(BaseCommand):
compete_url = "https://tinytorch.ai/community/compete"
self.console.print(f"[blue]🎯 Opening competitions...[/blue]")
self.console.print(f"[cyan]🎯 Opening competitions...[/cyan]")
try:
webbrowser.open(compete_url)
self.console.print(f"[green][/green] Browser opened: [cyan]{compete_url}[/cyan]")
self.console.print(f"[green][/green] Browser opened: [cyan]{compete_url}[/cyan]")
except Exception as e:
self.console.print(f"[yellow]⚠️ Could not open browser automatically[/yellow]")
self.console.print(f"[dim]Please visit: {compete_url}[/dim]")

View File

@@ -38,7 +38,6 @@ from .commands.milestone import MilestoneCommand
from .commands.setup import SetupCommand
from .commands.benchmark import BenchmarkCommand
from .commands.community import CommunityCommand
from .commands.login import LoginCommand, LogoutCommand
# Configure logging
logging.basicConfig(
@@ -80,9 +79,6 @@ class TinyTorchCLI:
'test': TestCommand,
'grade': GradeCommand,
'logo': LogoCommand,
# Authentication commands
'login': LoginCommand,
'logout': LogoutCommand,
}
def create_parser(self) -> argparse.ArgumentParser:
@@ -206,7 +202,8 @@ Quick Start:
help_text += " [yellow]tito module status[/yellow] View module progress\n"
help_text += " [yellow]tito milestones status[/yellow] View unlocked capabilities\n"
help_text += "\n[bold cyan]Community:[/bold cyan]\n"
help_text += " [blue]tito community join[/blue] Connect with builders worldwide\n"
help_text += " [blue]tito community login[/blue] Log in to TinyTorch\n"
help_text += " [blue]tito community leaderboard[/blue] View global leaderboard\n"
help_text += "\n[bold cyan]Help & Docs:[/bold cyan]\n"
help_text += " [magenta]tito system doctor[/magenta] Check environment health\n"
help_text += " [magenta]tito --help[/magenta] See all commands"