Spaces:
Running
Running
| """ | |
| Hugging Face Contributions Graph | |
| A GitHub-style contribution calendar for Hugging Face users. | |
| Shows daily commit activity across models, datasets, and spaces. | |
| """ | |
| import gradio as gr | |
| from datetime import datetime, timedelta | |
| from hf_contributions import fetch_user_contributions, ContributionStats | |
| from typing import Optional | |
| import json | |
| # GitHub-style color palette | |
| COLORS = { | |
| "light": { | |
| 0: "#ebedf0", # No contributions | |
| 1: "#9be9a8", # Level 1 (1-3) | |
| 2: "#40c463", # Level 2 (4-6) | |
| 3: "#30a14e", # Level 3 (7-9) | |
| 4: "#216e39", # Level 4 (10+) | |
| }, | |
| "dark": { | |
| 0: "#161b22", | |
| 1: "#0e4429", | |
| 2: "#006d32", | |
| 3: "#26a641", | |
| 4: "#39d353", | |
| } | |
| } | |
| def get_contribution_level(count: int) -> int: | |
| """Determine the contribution level (0-4) based on count.""" | |
| if count == 0: | |
| return 0 | |
| elif count <= 3: | |
| return 1 | |
| elif count <= 6: | |
| return 2 | |
| elif count <= 9: | |
| return 3 | |
| else: | |
| return 4 | |
| def generate_calendar_data(contributions: dict[str, int], days: int = 365) -> list[dict]: | |
| """Generate calendar data for the past N days.""" | |
| today = datetime.now().date() | |
| start_date = today - timedelta(days=days - 1) | |
| # Adjust to start from Sunday | |
| days_since_sunday = start_date.weekday() + 1 | |
| if days_since_sunday == 7: | |
| days_since_sunday = 0 | |
| start_date = start_date - timedelta(days=days_since_sunday) | |
| calendar_data = [] | |
| current_date = start_date | |
| while current_date <= today: | |
| date_str = current_date.strftime("%Y-%m-%d") | |
| count = contributions.get(date_str, 0) | |
| level = get_contribution_level(count) | |
| calendar_data.append({ | |
| "date": date_str, | |
| "count": count, | |
| "level": level, | |
| "weekday": current_date.weekday(), | |
| "month": current_date.month, | |
| "day": current_date.day | |
| }) | |
| current_date += timedelta(days=1) | |
| return calendar_data | |
| def build_daily_activity_data(contributions_by_repo: dict) -> dict[str, list[dict]]: | |
| """Build a mapping of date -> list of contributions for that date.""" | |
| daily_activities = {} | |
| for repo_id, commits in contributions_by_repo.items(): | |
| for commit in commits: | |
| date = commit["date"] | |
| if date not in daily_activities: | |
| daily_activities[date] = [] | |
| daily_activities[date].append({ | |
| "repo_id": repo_id, | |
| "repo_type": commit["repo_type"], | |
| "message": commit.get("message", "No message")[:80] | |
| }) | |
| return daily_activities | |
| def generate_contribution_graph_html( | |
| stats: Optional[ContributionStats], | |
| theme: str = "light", | |
| username: str = "" | |
| ) -> str: | |
| """Generate the HTML/CSS for the GitHub-style contribution graph.""" | |
| if stats is None: | |
| return """ | |
| <div style="text-align: center; padding: 40px; color: #666;"> | |
| <p>Enter a Hugging Face username to see their contribution graph</p> | |
| </div> | |
| """ | |
| colors = COLORS[theme] | |
| calendar_data = generate_calendar_data(stats.contributions_by_date) | |
| # Build daily activity data for the detail view | |
| daily_activities = build_daily_activity_data(stats.contributions_by_repo) | |
| daily_activities_json = json.dumps(daily_activities) | |
| # Group by weeks | |
| weeks = [] | |
| current_week = [] | |
| for day in calendar_data: | |
| if len(current_week) == 7: | |
| weeks.append(current_week) | |
| current_week = [] | |
| current_week.append(day) | |
| if current_week: | |
| weeks.append(current_week) | |
| # Generate month labels | |
| months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", | |
| "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"] | |
| month_positions = {} | |
| for week_idx, week in enumerate(weeks): | |
| for day in week: | |
| if day["day"] <= 7: # First week of month | |
| month_positions[week_idx] = months[day["month"] - 1] | |
| break | |
| # Build HTML | |
| squares_html = "" | |
| for week_idx, week in enumerate(weeks): | |
| for day in week: | |
| color = colors[day["level"]] | |
| tooltip = f'{day["count"]} contributions on {day["date"]}' | |
| squares_html += f''' | |
| <div class="day" | |
| data-date="{day["date"]}" | |
| data-count="{day["count"]}" | |
| data-level="{day["level"]}" | |
| style="background-color: {color};" | |
| title="{tooltip}" | |
| onclick="window.selectDay('{day["date"]}', {day["count"]})"> | |
| </div> | |
| ''' | |
| # Month labels HTML | |
| months_html = "" | |
| last_month_week = -4 | |
| for week_idx, month_name in sorted(month_positions.items()): | |
| if week_idx - last_month_week >= 4: # At least 4 weeks apart | |
| left_pos = week_idx * 15 # 11px square + 4px gap | |
| months_html += f'<span style="position: absolute; left: {left_pos}px;">{month_name}</span>' | |
| last_month_week = week_idx | |
| bg_color = "#ffffff" if theme == "light" else "#0d1117" | |
| text_color = "#24292f" if theme == "light" else "#c9d1d9" | |
| border_color = "#d0d7de" if theme == "light" else "#30363d" | |
| html = f''' | |
| <style> | |
| .contribution-graph {{ | |
| font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif; | |
| background: {bg_color}; | |
| padding: 16px; | |
| border-radius: 6px; | |
| border: 1px solid {border_color}; | |
| overflow-x: auto; | |
| }} | |
| .graph-header {{ | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: center; | |
| margin-bottom: 8px; | |
| color: {text_color}; | |
| }} | |
| .graph-title {{ | |
| font-size: 16px; | |
| font-weight: 600; | |
| }} | |
| .graph-container {{ | |
| display: flex; | |
| gap: 4px; | |
| }} | |
| .days-labels {{ | |
| display: flex; | |
| flex-direction: column; | |
| justify-content: space-around; | |
| font-size: 10px; | |
| color: {text_color}; | |
| padding-right: 8px; | |
| height: 91px; | |
| }} | |
| .days-labels span {{ | |
| height: 11px; | |
| line-height: 11px; | |
| }} | |
| .calendar-wrapper {{ | |
| position: relative; | |
| }} | |
| .months-row {{ | |
| position: relative; | |
| height: 15px; | |
| font-size: 10px; | |
| color: {text_color}; | |
| margin-bottom: 4px; | |
| }} | |
| .calendar {{ | |
| display: grid; | |
| grid-template-rows: repeat(7, 11px); | |
| grid-auto-flow: column; | |
| grid-auto-columns: 11px; | |
| gap: 4px; | |
| }} | |
| .day {{ | |
| width: 11px; | |
| height: 11px; | |
| border-radius: 2px; | |
| cursor: pointer; | |
| transition: transform 0.1s ease; | |
| }} | |
| .day:hover {{ | |
| transform: scale(1.3); | |
| outline: 1px solid {text_color}; | |
| outline-offset: 1px; | |
| }} | |
| .legend {{ | |
| display: flex; | |
| align-items: center; | |
| justify-content: flex-end; | |
| gap: 4px; | |
| margin-top: 8px; | |
| font-size: 11px; | |
| color: {text_color}; | |
| }} | |
| .legend-item {{ | |
| width: 11px; | |
| height: 11px; | |
| border-radius: 2px; | |
| }} | |
| .stats-grid {{ | |
| display: grid; | |
| grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); | |
| gap: 16px; | |
| margin-top: 16px; | |
| padding-top: 16px; | |
| border-top: 1px solid {border_color}; | |
| }} | |
| .stat-card {{ | |
| text-align: center; | |
| padding: 12px; | |
| background: {colors[0]}; | |
| border-radius: 6px; | |
| }} | |
| .stat-value {{ | |
| font-size: 24px; | |
| font-weight: 600; | |
| color: {text_color}; | |
| }} | |
| .stat-label {{ | |
| font-size: 12px; | |
| color: {text_color}; | |
| opacity: 0.8; | |
| }} | |
| .activity-section {{ | |
| margin-top: 16px; | |
| padding-top: 16px; | |
| border-top: 1px solid {border_color}; | |
| display: none; | |
| }} | |
| .activity-section.active {{ | |
| display: block; | |
| }} | |
| .activity-header {{ | |
| display: flex; | |
| align-items: center; | |
| gap: 8px; | |
| margin-bottom: 12px; | |
| color: {text_color}; | |
| }} | |
| .activity-date {{ | |
| font-size: 16px; | |
| font-weight: 600; | |
| }} | |
| .activity-count {{ | |
| font-size: 14px; | |
| color: {text_color}; | |
| opacity: 0.7; | |
| }} | |
| .activity-list {{ | |
| display: flex; | |
| flex-direction: column; | |
| gap: 8px; | |
| }} | |
| .activity-item {{ | |
| display: flex; | |
| align-items: flex-start; | |
| gap: 12px; | |
| padding: 12px; | |
| background: {colors[0]}; | |
| border-radius: 6px; | |
| border-left: 3px solid #30a14e; | |
| }} | |
| .activity-item.model {{ | |
| border-left-color: #9b59b6; | |
| }} | |
| .activity-item.dataset {{ | |
| border-left-color: #3498db; | |
| }} | |
| .activity-item.space {{ | |
| border-left-color: #e67e22; | |
| }} | |
| .activity-icon {{ | |
| width: 32px; | |
| height: 32px; | |
| border-radius: 6px; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| font-size: 16px; | |
| flex-shrink: 0; | |
| }} | |
| .activity-icon.model {{ | |
| background: rgba(155, 89, 182, 0.15); | |
| }} | |
| .activity-icon.dataset {{ | |
| background: rgba(52, 152, 219, 0.15); | |
| }} | |
| .activity-icon.space {{ | |
| background: rgba(230, 126, 34, 0.15); | |
| }} | |
| .activity-content {{ | |
| flex: 1; | |
| min-width: 0; | |
| }} | |
| .activity-repo {{ | |
| font-weight: 600; | |
| color: #0969da; | |
| text-decoration: none; | |
| font-size: 14px; | |
| }} | |
| .activity-repo:hover {{ | |
| text-decoration: underline; | |
| }} | |
| .activity-type {{ | |
| font-size: 12px; | |
| color: {text_color}; | |
| opacity: 0.7; | |
| margin-left: 8px; | |
| }} | |
| .activity-message {{ | |
| font-size: 13px; | |
| color: {text_color}; | |
| opacity: 0.8; | |
| margin-top: 4px; | |
| white-space: nowrap; | |
| overflow: hidden; | |
| text-overflow: ellipsis; | |
| }} | |
| .no-activity {{ | |
| text-align: center; | |
| padding: 20px; | |
| color: {text_color}; | |
| opacity: 0.6; | |
| }} | |
| </style> | |
| <div class="contribution-graph"> | |
| <div class="graph-header"> | |
| <span class="graph-title">{stats.total_commits} contributions in the last year</span> | |
| </div> | |
| <div class="graph-container"> | |
| <div class="days-labels"> | |
| <span></span> | |
| <span>Mon</span> | |
| <span></span> | |
| <span>Wed</span> | |
| <span></span> | |
| <span>Fri</span> | |
| <span></span> | |
| </div> | |
| <div class="calendar-wrapper"> | |
| <div class="months-row"> | |
| {months_html} | |
| </div> | |
| <div class="calendar"> | |
| {squares_html} | |
| </div> | |
| </div> | |
| </div> | |
| <div class="legend"> | |
| <span>Less</span> | |
| <div class="legend-item" style="background-color: {colors[0]};"></div> | |
| <div class="legend-item" style="background-color: {colors[1]};"></div> | |
| <div class="legend-item" style="background-color: {colors[2]};"></div> | |
| <div class="legend-item" style="background-color: {colors[3]};"></div> | |
| <div class="legend-item" style="background-color: {colors[4]};"></div> | |
| <span>More</span> | |
| </div> | |
| <div class="stats-grid"> | |
| <div class="stat-card"> | |
| <div class="stat-value">{stats.total_commits}</div> | |
| <div class="stat-label">Total Contributions</div> | |
| </div> | |
| <div class="stat-card"> | |
| <div class="stat-value">{stats.models_count}</div> | |
| <div class="stat-label">Models</div> | |
| </div> | |
| <div class="stat-card"> | |
| <div class="stat-value">{stats.datasets_count}</div> | |
| <div class="stat-label">Datasets</div> | |
| </div> | |
| <div class="stat-card"> | |
| <div class="stat-value">{stats.spaces_count}</div> | |
| <div class="stat-label">Spaces</div> | |
| </div> | |
| <div class="stat-card"> | |
| <div class="stat-value">{stats.longest_streak}</div> | |
| <div class="stat-label">Longest Streak</div> | |
| </div> | |
| <div class="stat-card"> | |
| <div class="stat-value">{stats.current_streak}</div> | |
| <div class="stat-label">Current Streak</div> | |
| </div> | |
| </div> | |
| <div id="activity-section" class="activity-section"> | |
| <div class="activity-header"> | |
| <span class="activity-date" id="activity-date"></span> | |
| <span class="activity-count" id="activity-count"></span> | |
| </div> | |
| <div class="activity-list" id="activity-list"> | |
| </div> | |
| </div> | |
| </div> | |
| <script> | |
| const dailyActivities = {daily_activities_json}; | |
| const typeIcons = {{ | |
| 'model': '🤖', | |
| 'dataset': '📊', | |
| 'space': '🚀' | |
| }}; | |
| const typeLabels = {{ | |
| 'model': 'Model', | |
| 'dataset': 'Dataset', | |
| 'space': 'Space' | |
| }}; | |
| function getHfUrl(repoId, repoType) {{ | |
| if (repoType === 'model') return 'https://huggingface.co/' + repoId; | |
| if (repoType === 'dataset') return 'https://huggingface.co/datasets/' + repoId; | |
| if (repoType === 'space') return 'https://huggingface.co/spaces/' + repoId; | |
| return 'https://huggingface.co/' + repoId; | |
| }} | |
| function escapeHtml(text) {{ | |
| const div = document.createElement('div'); | |
| div.textContent = text; | |
| return div.innerHTML; | |
| }} | |
| window.selectDay = function(date, count) {{ | |
| const section = document.getElementById('activity-section'); | |
| const dateEl = document.getElementById('activity-date'); | |
| const countEl = document.getElementById('activity-count'); | |
| const listEl = document.getElementById('activity-list'); | |
| // Format date nicely | |
| const dateObj = new Date(date + 'T00:00:00'); | |
| const options = {{ weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' }}; | |
| dateEl.textContent = dateObj.toLocaleDateString('en-US', options); | |
| countEl.textContent = count + ' contribution' + (count !== 1 ? 's' : ''); | |
| // Build activity list | |
| const activities = dailyActivities[date] || []; | |
| // Clear list using safe method | |
| while (listEl.firstChild) {{ | |
| listEl.removeChild(listEl.firstChild); | |
| }} | |
| if (activities.length === 0) {{ | |
| const noActivity = document.createElement('div'); | |
| noActivity.className = 'no-activity'; | |
| noActivity.textContent = 'No contributions on this day'; | |
| listEl.appendChild(noActivity); | |
| }} else {{ | |
| // Group by repo to avoid duplicates | |
| const repoMap = {{}}; | |
| activities.forEach(a => {{ | |
| if (!repoMap[a.repo_id]) {{ | |
| repoMap[a.repo_id] = {{ ...a, count: 1 }}; | |
| }} else {{ | |
| repoMap[a.repo_id].count++; | |
| }} | |
| }}); | |
| Object.values(repoMap).forEach(activity => {{ | |
| const url = getHfUrl(activity.repo_id, activity.repo_type); | |
| const icon = typeIcons[activity.repo_type] || '📁'; | |
| const label = typeLabels[activity.repo_type] || 'Repository'; | |
| const commitText = activity.count > 1 ? activity.count + ' commits' : '1 commit'; | |
| // Build DOM safely | |
| const item = document.createElement('div'); | |
| item.className = 'activity-item ' + activity.repo_type; | |
| const iconDiv = document.createElement('div'); | |
| iconDiv.className = 'activity-icon ' + activity.repo_type; | |
| iconDiv.textContent = icon; | |
| const contentDiv = document.createElement('div'); | |
| contentDiv.className = 'activity-content'; | |
| const link = document.createElement('a'); | |
| link.href = url; | |
| link.target = '_blank'; | |
| link.className = 'activity-repo'; | |
| link.textContent = activity.repo_id; | |
| const typeSpan = document.createElement('span'); | |
| typeSpan.className = 'activity-type'; | |
| typeSpan.textContent = label + ' · ' + commitText; | |
| const messageDiv = document.createElement('div'); | |
| messageDiv.className = 'activity-message'; | |
| messageDiv.textContent = activity.message; | |
| contentDiv.appendChild(link); | |
| contentDiv.appendChild(typeSpan); | |
| contentDiv.appendChild(messageDiv); | |
| item.appendChild(iconDiv); | |
| item.appendChild(contentDiv); | |
| listEl.appendChild(item); | |
| }}); | |
| }} | |
| section.classList.add('active'); | |
| // Highlight selected day | |
| document.querySelectorAll('.day').forEach(d => d.style.outline = ''); | |
| event.target.style.outline = '2px solid #0969da'; | |
| event.target.style.outlineOffset = '1px'; | |
| // Scroll to activity section | |
| section.scrollIntoView({{ behavior: 'smooth', block: 'nearest' }}); | |
| }} | |
| </script> | |
| ''' | |
| return html | |
| # Store the current stats globally for the detail view | |
| current_stats: Optional[ContributionStats] = None | |
| def fetch_and_display(username: str, theme: str) -> tuple[str, str]: | |
| """Fetch contributions and generate the display.""" | |
| global current_stats | |
| if not username or not username.strip(): | |
| return ( | |
| generate_contribution_graph_html(None, theme), | |
| "Please enter a username" | |
| ) | |
| username = username.strip() | |
| try: | |
| # Fetch the data | |
| current_stats = fetch_user_contributions(username) | |
| # Generate the graph HTML | |
| graph_html = generate_contribution_graph_html(current_stats, theme, username) | |
| # Generate summary | |
| summary = f""" | |
| ### {username}'s Hugging Face Activity | |
| - **Total Contributions:** {current_stats.total_commits} | |
| - **Repositories:** {current_stats.total_repos} ({current_stats.models_count} models, {current_stats.datasets_count} datasets, {current_stats.spaces_count} spaces) | |
| - **Longest Streak:** {current_stats.longest_streak} days | |
| - **Current Streak:** {current_stats.current_streak} days | |
| """ | |
| if current_stats.most_active_day: | |
| summary += f"- **Most Active Day:** {current_stats.most_active_day} ({current_stats.most_active_count} contributions)" | |
| return graph_html, summary | |
| except Exception as e: | |
| return ( | |
| f'<div style="color: red; padding: 20px;">Error: {str(e)}</div>', | |
| f"Error fetching data for {username}: {str(e)}" | |
| ) | |
| # Create the Gradio interface | |
| with gr.Blocks( | |
| title="HF Contributions Graph", | |
| theme=gr.themes.Soft(), | |
| css=""" | |
| .gradio-container { max-width: 1200px !important; } | |
| footer { display: none !important; } | |
| """ | |
| ) as demo: | |
| gr.Markdown(""" | |
| # 🤗 Hugging Face Contributions Graph | |
| GitHub-style contribution calendar for Hugging Face users. | |
| See your daily activity across models, datasets, and spaces! | |
| """) | |
| with gr.Row(): | |
| with gr.Column(scale=4): | |
| username_input = gr.Textbox( | |
| label="Hugging Face Username", | |
| placeholder="e.g., huggingface, Qwen, meta-llama", | |
| lines=1 | |
| ) | |
| with gr.Column(scale=1): | |
| theme_dropdown = gr.Dropdown( | |
| choices=["light", "dark"], | |
| value="light", | |
| label="Theme" | |
| ) | |
| with gr.Column(scale=1): | |
| fetch_btn = gr.Button("Fetch Contributions", variant="primary") | |
| graph_output = gr.HTML( | |
| value=generate_contribution_graph_html(None, "light"), | |
| label="Contribution Graph" | |
| ) | |
| summary_output = gr.Markdown( | |
| value="Enter a username and click 'Fetch Contributions' to see their activity." | |
| ) | |
| # Event handlers | |
| fetch_btn.click( | |
| fn=fetch_and_display, | |
| inputs=[username_input, theme_dropdown], | |
| outputs=[graph_output, summary_output] | |
| ) | |
| username_input.submit( | |
| fn=fetch_and_display, | |
| inputs=[username_input, theme_dropdown], | |
| outputs=[graph_output, summary_output] | |
| ) | |
| theme_dropdown.change( | |
| fn=lambda u, t: fetch_and_display(u, t) if u else (generate_contribution_graph_html(None, t), ""), | |
| inputs=[username_input, theme_dropdown], | |
| outputs=[graph_output, summary_output] | |
| ) | |
| gr.Markdown(""" | |
| --- | |
| **How it works:** This app fetches your public repositories (models, datasets, spaces) | |
| from the Hugging Face Hub API and counts commits to generate a contribution calendar. | |
| **Note:** Only public repositories are counted. Private repos require authentication. | |
| """) | |
| if __name__ == "__main__": | |
| demo.launch() | |