|
|
@@ -13,6 +13,7 @@ import subprocess
|
|
13
|
13
|
import sys
|
|
14
|
14
|
import time
|
|
15
|
15
|
import zlib
|
|
|
16
|
+from fpdf import FPDF
|
|
16
|
17
|
|
|
17
|
18
|
if sys.version_info < (3, 6):
|
|
18
|
19
|
print("Python 3.6 or higher is required for gitstats", file=sys.stderr)
|
|
|
@@ -47,7 +48,8 @@ conf = {
|
|
47
|
48
|
'linear_linestats': 1,
|
|
48
|
49
|
'project_name': '',
|
|
49
|
50
|
'processes': 8,
|
|
50
|
|
- 'start_date': ''
|
|
|
51
|
+ 'start_date': '',
|
|
|
52
|
+ 'output_format': 'html' # 'html', 'pdf', or 'both'
|
|
51
|
53
|
}
|
|
52
|
54
|
|
|
53
|
55
|
def getpipeoutput(cmds, quiet = False):
|
|
|
@@ -1419,6 +1421,524 @@ plot """
|
|
1419
|
1421
|
</ul>
|
|
1420
|
1422
|
</div>
|
|
1421
|
1423
|
""")
|
|
|
1424
|
+
|
|
|
1425
|
+class PDFReportCreator(ReportCreator):
|
|
|
1426
|
+ """Creates PDF reports using fpdf2 library with embedded charts and tab-based structure."""
|
|
|
1427
|
+
|
|
|
1428
|
+ def __init__(self):
|
|
|
1429
|
+ ReportCreator.__init__(self)
|
|
|
1430
|
+ self.pdf = None
|
|
|
1431
|
+ self.output_path = None
|
|
|
1432
|
+
|
|
|
1433
|
+ def create(self, data, path):
|
|
|
1434
|
+ ReportCreator.create(self, data, path)
|
|
|
1435
|
+ self.title = data.projectname
|
|
|
1436
|
+ self.output_path = path
|
|
|
1437
|
+
|
|
|
1438
|
+ # Initialize PDF document
|
|
|
1439
|
+ self.pdf = FPDF()
|
|
|
1440
|
+ self.pdf.set_auto_page_break(auto=True, margin=15)
|
|
|
1441
|
+
|
|
|
1442
|
+ # Create all pages (tabs)
|
|
|
1443
|
+ self._create_title_page(data)
|
|
|
1444
|
+ self._create_general_page(data)
|
|
|
1445
|
+ self._create_activity_page(data)
|
|
|
1446
|
+ self._create_authors_page(data)
|
|
|
1447
|
+ self._create_files_page(data)
|
|
|
1448
|
+ self._create_lines_page(data)
|
|
|
1449
|
+ self._create_tags_page(data)
|
|
|
1450
|
+
|
|
|
1451
|
+ # Save PDF
|
|
|
1452
|
+ pdf_path = os.path.join(path, f"gitstats_{data.projectname.replace(' ', '_')}.pdf")
|
|
|
1453
|
+ self.pdf.output(pdf_path)
|
|
|
1454
|
+ print(f"PDF report saved to: {pdf_path}")
|
|
|
1455
|
+
|
|
|
1456
|
+ def _add_chart_if_exists(self, chart_filename, width=None, height=None):
|
|
|
1457
|
+ """Add a chart image to the PDF if it exists."""
|
|
|
1458
|
+ chart_path = os.path.join(self.output_path, chart_filename)
|
|
|
1459
|
+ if os.path.exists(chart_path):
|
|
|
1460
|
+ try:
|
|
|
1461
|
+ # Get current position
|
|
|
1462
|
+ x = self.pdf.get_x()
|
|
|
1463
|
+ y = self.pdf.get_y()
|
|
|
1464
|
+
|
|
|
1465
|
+ # Calculate dimensions
|
|
|
1466
|
+ if width is None:
|
|
|
1467
|
+ width = 150 # Default width
|
|
|
1468
|
+ if height is None:
|
|
|
1469
|
+ height = 80 # Default height
|
|
|
1470
|
+
|
|
|
1471
|
+ # Check if there's enough space, if not add page break
|
|
|
1472
|
+ if y + height > 280: # 280 is roughly page height minus margin
|
|
|
1473
|
+ self.pdf.add_page()
|
|
|
1474
|
+ y = self.pdf.get_y()
|
|
|
1475
|
+
|
|
|
1476
|
+ # Add image
|
|
|
1477
|
+ self.pdf.image(chart_path, x, y, width, height)
|
|
|
1478
|
+
|
|
|
1479
|
+ # Move cursor below image
|
|
|
1480
|
+ self.pdf.set_y(y + height + 5)
|
|
|
1481
|
+
|
|
|
1482
|
+ return True
|
|
|
1483
|
+ except Exception as e:
|
|
|
1484
|
+ print(f"Warning: Could not add chart {chart_filename}: {e}")
|
|
|
1485
|
+ return False
|
|
|
1486
|
+ return False
|
|
|
1487
|
+
|
|
|
1488
|
+ def _create_title_page(self, data):
|
|
|
1489
|
+ """Create the title page of the PDF report."""
|
|
|
1490
|
+ self.pdf.add_page()
|
|
|
1491
|
+ self.pdf.set_font('Arial', 'B', 24)
|
|
|
1492
|
+ self.pdf.cell(0, 20, f'GitStats Report - {data.projectname}', 0, 1, 'C')
|
|
|
1493
|
+
|
|
|
1494
|
+ self.pdf.ln(10)
|
|
|
1495
|
+ self.pdf.set_font('Arial', '', 12)
|
|
|
1496
|
+ format = '%Y-%m-%d %H:%M:%S'
|
|
|
1497
|
+
|
|
|
1498
|
+ # Report generation info
|
|
|
1499
|
+ self.pdf.cell(0, 10, f'Generated: {datetime.datetime.now().strftime(format)}', 0, 1)
|
|
|
1500
|
+ self.pdf.cell(0, 10, f'Generator: GitStats (version {getversion()})', 0, 1)
|
|
|
1501
|
+ self.pdf.cell(0, 10, f'Git Version: {getgitversion()}', 0, 1)
|
|
|
1502
|
+ if getgnuplotversion():
|
|
|
1503
|
+ self.pdf.cell(0, 10, f'Gnuplot Version: {getgnuplotversion()}', 0, 1)
|
|
|
1504
|
+
|
|
|
1505
|
+ self.pdf.ln(10)
|
|
|
1506
|
+ self.pdf.cell(0, 10, f'Report Period: {data.getFirstCommitDate().strftime(format)} to {data.getLastCommitDate().strftime(format)}', 0, 1)
|
|
|
1507
|
+
|
|
|
1508
|
+ # Table of contents
|
|
|
1509
|
+ self.pdf.ln(15)
|
|
|
1510
|
+ self.pdf.set_font('Arial', 'B', 16)
|
|
|
1511
|
+ self.pdf.cell(0, 10, 'Table of Contents', 0, 1, 'L')
|
|
|
1512
|
+
|
|
|
1513
|
+ self.pdf.set_font('Arial', '', 12)
|
|
|
1514
|
+ sections = [
|
|
|
1515
|
+ '1. General Statistics',
|
|
|
1516
|
+ '2. Activity Statistics',
|
|
|
1517
|
+ '3. Authors Statistics',
|
|
|
1518
|
+ '4. Files Statistics',
|
|
|
1519
|
+ '5. Lines of Code Statistics',
|
|
|
1520
|
+ '6. Tags Statistics'
|
|
|
1521
|
+ ]
|
|
|
1522
|
+
|
|
|
1523
|
+ for section in sections:
|
|
|
1524
|
+ self.pdf.cell(0, 8, section, 0, 1, 'L')
|
|
|
1525
|
+
|
|
|
1526
|
+ def _create_general_page(self, data):
|
|
|
1527
|
+ """Create the general statistics page (mirrors index.html)."""
|
|
|
1528
|
+ self.pdf.add_page()
|
|
|
1529
|
+ self.pdf.set_font('Arial', 'B', 20)
|
|
|
1530
|
+ self.pdf.cell(0, 15, '1. General Statistics', 0, 1, 'L')
|
|
|
1531
|
+
|
|
|
1532
|
+ self.pdf.set_font('Arial', '', 12)
|
|
|
1533
|
+
|
|
|
1534
|
+ # Calculate basic stats
|
|
|
1535
|
+ total_commits = data.getTotalCommits()
|
|
|
1536
|
+ total_active_days = len(data.getActiveDays()) if hasattr(data, 'getActiveDays') else 0
|
|
|
1537
|
+ delta_days = data.getCommitDeltaDays() if hasattr(data, 'getCommitDeltaDays') else 0
|
|
|
1538
|
+ total_authors = data.getTotalAuthors()
|
|
|
1539
|
+
|
|
|
1540
|
+ # General statistics (matching index.html exactly)
|
|
|
1541
|
+ stats = [
|
|
|
1542
|
+ ('Project name', data.projectname),
|
|
|
1543
|
+ ('Generated', datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')),
|
|
|
1544
|
+ ('Report Period', f"{data.getFirstCommitDate().strftime('%Y-%m-%d %H:%M:%S')} to {data.getLastCommitDate().strftime('%Y-%m-%d %H:%M:%S')}"),
|
|
|
1545
|
+ ('Age', f"{delta_days} days, {total_active_days} active days ({(100.0 * total_active_days / delta_days) if delta_days else 0.0:.2f}%)"),
|
|
|
1546
|
+ ('Total Files', str(data.getTotalFiles())),
|
|
|
1547
|
+ ('Total Lines of Code', f"{data.getTotalLOC()} ({data.total_lines_added} added, {data.total_lines_removed} removed)"),
|
|
|
1548
|
+ ('Total Commits', f"{total_commits} (average {(float(total_commits) / total_active_days) if total_active_days else 0.0:.1f} commits per active day, {(float(total_commits) / delta_days) if delta_days else 0.0:.1f} per all days)"),
|
|
|
1549
|
+ ('Authors', f"{total_authors} (average {(float(total_commits) / total_authors) if total_authors else 0.0:.1f} commits per author)")
|
|
|
1550
|
+ ]
|
|
|
1551
|
+
|
|
|
1552
|
+ # Display stats
|
|
|
1553
|
+ for label, value in stats:
|
|
|
1554
|
+ self.pdf.cell(50, 8, f"{label}:", 0, 0, 'L')
|
|
|
1555
|
+ self.pdf.cell(0, 8, str(value), 0, 1, 'L')
|
|
|
1556
|
+
|
|
|
1557
|
+ self.pdf.ln(10)
|
|
|
1558
|
+
|
|
|
1559
|
+ def _create_activity_page(self, data):
|
|
|
1560
|
+ """Create the activity statistics page with charts (mirrors activity.html)."""
|
|
|
1561
|
+ self.pdf.add_page()
|
|
|
1562
|
+ self.pdf.set_font('Arial', 'B', 20)
|
|
|
1563
|
+ self.pdf.cell(0, 15, '2. Activity Statistics', 0, 1, 'L')
|
|
|
1564
|
+
|
|
|
1565
|
+ # Weekly activity section
|
|
|
1566
|
+ self.pdf.set_font('Arial', 'B', 14)
|
|
|
1567
|
+ self.pdf.cell(0, 10, 'Weekly Activity', 0, 1, 'L')
|
|
|
1568
|
+ self.pdf.set_font('Arial', '', 10)
|
|
|
1569
|
+ self.pdf.cell(0, 6, 'Last 32 weeks activity (see chart below)', 0, 1, 'L')
|
|
|
1570
|
+ self.pdf.ln(5)
|
|
|
1571
|
+
|
|
|
1572
|
+ # Hour of Day section
|
|
|
1573
|
+ self.pdf.set_font('Arial', 'B', 14)
|
|
|
1574
|
+ self.pdf.cell(0, 10, 'Hour of Day', 0, 1, 'L')
|
|
|
1575
|
+
|
|
|
1576
|
+ self.pdf.set_font('Arial', '', 10)
|
|
|
1577
|
+ hour_of_day = data.getActivityByHourOfDay()
|
|
|
1578
|
+ total_commits = data.getTotalCommits()
|
|
|
1579
|
+
|
|
|
1580
|
+ # Create hour of day table
|
|
|
1581
|
+ self.pdf.set_font('Arial', 'B', 8)
|
|
|
1582
|
+ self.pdf.cell(20, 6, 'Hour', 1, 0, 'C')
|
|
|
1583
|
+ for h in range(0, 24):
|
|
|
1584
|
+ self.pdf.cell(7, 6, str(h), 1, 0, 'C')
|
|
|
1585
|
+ self.pdf.ln()
|
|
|
1586
|
+
|
|
|
1587
|
+ self.pdf.cell(20, 6, 'Commits', 1, 0, 'C')
|
|
|
1588
|
+ for h in range(0, 24):
|
|
|
1589
|
+ commits = hour_of_day.get(h, 0)
|
|
|
1590
|
+ self.pdf.cell(7, 6, str(commits), 1, 0, 'C')
|
|
|
1591
|
+ self.pdf.ln()
|
|
|
1592
|
+
|
|
|
1593
|
+ self.pdf.cell(20, 6, '%', 1, 0, 'C')
|
|
|
1594
|
+ for h in range(0, 24):
|
|
|
1595
|
+ commits = hour_of_day.get(h, 0)
|
|
|
1596
|
+ percent = (100.0 * commits / total_commits) if total_commits else 0.0
|
|
|
1597
|
+ self.pdf.cell(7, 6, f"{percent:.1f}", 1, 0, 'C')
|
|
|
1598
|
+ self.pdf.ln(10)
|
|
|
1599
|
+
|
|
|
1600
|
+ # Add hour of day chart
|
|
|
1601
|
+ self._add_chart_if_exists('hour_of_day.png', 180, 90)
|
|
|
1602
|
+
|
|
|
1603
|
+ # Day of Week section
|
|
|
1604
|
+ self.pdf.set_font('Arial', 'B', 14)
|
|
|
1605
|
+ self.pdf.cell(0, 10, 'Day of Week', 0, 1, 'L')
|
|
|
1606
|
+
|
|
|
1607
|
+ self.pdf.set_font('Arial', '', 10)
|
|
|
1608
|
+ day_of_week = data.getActivityByDayOfWeek()
|
|
|
1609
|
+
|
|
|
1610
|
+ # Create day of week table
|
|
|
1611
|
+ self.pdf.set_font('Arial', 'B', 10)
|
|
|
1612
|
+ self.pdf.cell(30, 8, 'Day', 1, 0, 'C')
|
|
|
1613
|
+ self.pdf.cell(30, 8, 'Total (%)', 1, 1, 'C')
|
|
|
1614
|
+
|
|
|
1615
|
+ self.pdf.set_font('Arial', '', 10)
|
|
|
1616
|
+ for d in range(0, 7):
|
|
|
1617
|
+ day_name = WEEKDAYS[d]
|
|
|
1618
|
+ commits = day_of_week.get(d, 0)
|
|
|
1619
|
+ percent = (100.0 * commits / total_commits) if total_commits else 0.0
|
|
|
1620
|
+ self.pdf.cell(30, 6, day_name, 1, 0, 'L')
|
|
|
1621
|
+ self.pdf.cell(30, 6, f"{commits} ({percent:.2f}%)", 1, 1, 'L')
|
|
|
1622
|
+
|
|
|
1623
|
+ self.pdf.ln(5)
|
|
|
1624
|
+ self._add_chart_if_exists('day_of_week.png', 180, 90)
|
|
|
1625
|
+
|
|
|
1626
|
+ # Month of Year section
|
|
|
1627
|
+ if hasattr(data, 'activity_by_month_of_year'):
|
|
|
1628
|
+ self.pdf.set_font('Arial', 'B', 14)
|
|
|
1629
|
+ self.pdf.cell(0, 10, 'Month of Year', 0, 1, 'L')
|
|
|
1630
|
+
|
|
|
1631
|
+ self.pdf.set_font('Arial', 'B', 10)
|
|
|
1632
|
+ self.pdf.cell(30, 8, 'Month', 1, 0, 'C')
|
|
|
1633
|
+ self.pdf.cell(40, 8, 'Commits (%)', 1, 1, 'C')
|
|
|
1634
|
+
|
|
|
1635
|
+ self.pdf.set_font('Arial', '', 10)
|
|
|
1636
|
+ for mm in range(1, 13):
|
|
|
1637
|
+ commits = data.activity_by_month_of_year.get(mm, 0)
|
|
|
1638
|
+ percent = (100.0 * commits / total_commits) if total_commits else 0.0
|
|
|
1639
|
+ self.pdf.cell(30, 6, str(mm), 1, 0, 'C')
|
|
|
1640
|
+ self.pdf.cell(40, 6, f"{commits} ({percent:.2f} %)", 1, 1, 'L')
|
|
|
1641
|
+
|
|
|
1642
|
+ self.pdf.ln(5)
|
|
|
1643
|
+ self._add_chart_if_exists('month_of_year.png', 180, 90)
|
|
|
1644
|
+
|
|
|
1645
|
+ # Add page break for next major chart
|
|
|
1646
|
+ if self.pdf.get_y() > 200:
|
|
|
1647
|
+ self.pdf.add_page()
|
|
|
1648
|
+
|
|
|
1649
|
+ # Commits by year/month chart
|
|
|
1650
|
+ self.pdf.set_font('Arial', 'B', 14)
|
|
|
1651
|
+ self.pdf.cell(0, 10, 'Commits by Year/Month', 0, 1, 'L')
|
|
|
1652
|
+ self._add_chart_if_exists('commits_by_year_month.png', 180, 100)
|
|
|
1653
|
+
|
|
|
1654
|
+ # Commits by year chart
|
|
|
1655
|
+ self.pdf.set_font('Arial', 'B', 14)
|
|
|
1656
|
+ self.pdf.cell(0, 10, 'Commits by Year', 0, 1, 'L')
|
|
|
1657
|
+ self._add_chart_if_exists('commits_by_year.png', 180, 100)
|
|
|
1658
|
+
|
|
|
1659
|
+ def _create_authors_page(self, data):
|
|
|
1660
|
+ """Create the authors statistics page with charts (mirrors authors.html)."""
|
|
|
1661
|
+ self.pdf.add_page()
|
|
|
1662
|
+ self.pdf.set_font('Arial', 'B', 20)
|
|
|
1663
|
+ self.pdf.cell(0, 15, '3. Authors Statistics', 0, 1, 'L')
|
|
|
1664
|
+
|
|
|
1665
|
+ # List of Authors table
|
|
|
1666
|
+ self.pdf.set_font('Arial', 'B', 14)
|
|
|
1667
|
+ self.pdf.cell(0, 10, 'List of Authors', 0, 1, 'L')
|
|
|
1668
|
+
|
|
|
1669
|
+ authors = data.getAuthors(conf['max_authors'])
|
|
|
1670
|
+
|
|
|
1671
|
+ # Table header
|
|
|
1672
|
+ self.pdf.set_font('Arial', 'B', 8)
|
|
|
1673
|
+ self.pdf.cell(35, 6, 'Author', 1, 0, 'C')
|
|
|
1674
|
+ self.pdf.cell(20, 6, 'Commits (%)', 1, 0, 'C')
|
|
|
1675
|
+ self.pdf.cell(15, 6, '+ lines', 1, 0, 'C')
|
|
|
1676
|
+ self.pdf.cell(15, 6, '- lines', 1, 0, 'C')
|
|
|
1677
|
+ self.pdf.cell(25, 6, 'First commit', 1, 0, 'C')
|
|
|
1678
|
+ self.pdf.cell(25, 6, 'Last commit', 1, 0, 'C')
|
|
|
1679
|
+ self.pdf.cell(20, 6, 'Age', 1, 0, 'C')
|
|
|
1680
|
+ self.pdf.cell(15, 6, 'Active days', 1, 1, 'C')
|
|
|
1681
|
+
|
|
|
1682
|
+ # Table data
|
|
|
1683
|
+ self.pdf.set_font('Arial', '', 7)
|
|
|
1684
|
+ for author in authors[:20]: # Top 20 authors
|
|
|
1685
|
+ info = data.getAuthorInfo(author)
|
|
|
1686
|
+
|
|
|
1687
|
+ # Truncate long author names
|
|
|
1688
|
+ display_author = author[:18] + "..." if len(author) > 21 else author
|
|
|
1689
|
+
|
|
|
1690
|
+ self.pdf.cell(35, 5, display_author, 1, 0, 'L')
|
|
|
1691
|
+ self.pdf.cell(20, 5, f"{info['commits']} ({info['commits_frac']:.1f}%)", 1, 0, 'C')
|
|
|
1692
|
+ self.pdf.cell(15, 5, str(info['lines_added']), 1, 0, 'C')
|
|
|
1693
|
+ self.pdf.cell(15, 5, str(info['lines_removed']), 1, 0, 'C')
|
|
|
1694
|
+ self.pdf.cell(25, 5, info['date_first'][:10], 1, 0, 'C')
|
|
|
1695
|
+ self.pdf.cell(25, 5, info['date_last'][:10], 1, 0, 'C')
|
|
|
1696
|
+
|
|
|
1697
|
+ # Calculate age
|
|
|
1698
|
+ try:
|
|
|
1699
|
+ age_days = (datetime.datetime.strptime(info['date_last'][:10], '%Y-%m-%d') -
|
|
|
1700
|
+ datetime.datetime.strptime(info['date_first'][:10], '%Y-%m-%d')).days
|
|
|
1701
|
+ age_text = f"{age_days} days" if age_days > 0 else "1 day"
|
|
|
1702
|
+ except:
|
|
|
1703
|
+ age_text = "N/A"
|
|
|
1704
|
+
|
|
|
1705
|
+ active_days = len(info.get('active_days', [0])) if 'active_days' in info else 1
|
|
|
1706
|
+
|
|
|
1707
|
+ self.pdf.cell(20, 5, age_text[:12], 1, 0, 'C')
|
|
|
1708
|
+ self.pdf.cell(15, 5, str(active_days), 1, 1, 'C')
|
|
|
1709
|
+
|
|
|
1710
|
+ self.pdf.ln(10)
|
|
|
1711
|
+
|
|
|
1712
|
+ # Lines of code by author chart
|
|
|
1713
|
+ self.pdf.set_font('Arial', 'B', 14)
|
|
|
1714
|
+ self.pdf.cell(0, 10, 'Cumulated Added Lines of Code per Author', 0, 1, 'L')
|
|
|
1715
|
+ self._add_chart_if_exists('lines_of_code_by_author.png', 180, 110)
|
|
|
1716
|
+
|
|
|
1717
|
+ # Commits per author chart
|
|
|
1718
|
+ self.pdf.set_font('Arial', 'B', 14)
|
|
|
1719
|
+ self.pdf.cell(0, 10, 'Commits per Author', 0, 1, 'L')
|
|
|
1720
|
+ self._add_chart_if_exists('commits_by_author.png', 180, 110)
|
|
|
1721
|
+
|
|
|
1722
|
+ # Commits by domains chart
|
|
|
1723
|
+ self.pdf.set_font('Arial', 'B', 14)
|
|
|
1724
|
+ self.pdf.cell(0, 10, 'Commits by Domains', 0, 1, 'L')
|
|
|
1725
|
+ self._add_chart_if_exists('domains.png', 180, 100)
|
|
|
1726
|
+
|
|
|
1727
|
+ def _create_files_page(self, data):
|
|
|
1728
|
+ """Create the files statistics page with charts (mirrors files.html)."""
|
|
|
1729
|
+ self.pdf.add_page()
|
|
|
1730
|
+ self.pdf.set_font('Arial', 'B', 20)
|
|
|
1731
|
+ self.pdf.cell(0, 15, '4. Files Statistics', 0, 1, 'L')
|
|
|
1732
|
+
|
|
|
1733
|
+ # Basic file stats
|
|
|
1734
|
+ total_files = data.getTotalFiles()
|
|
|
1735
|
+ total_loc = data.getTotalLOC()
|
|
|
1736
|
+
|
|
|
1737
|
+ self.pdf.set_font('Arial', '', 12)
|
|
|
1738
|
+ stats = [
|
|
|
1739
|
+ ('Total files', str(total_files)),
|
|
|
1740
|
+ ('Total lines', str(total_loc)),
|
|
|
1741
|
+ ]
|
|
|
1742
|
+
|
|
|
1743
|
+ try:
|
|
|
1744
|
+ avg_size = float(data.getTotalSize()) / total_files if total_files else 0.0
|
|
|
1745
|
+ stats.append(('Average file size', f"{avg_size:.2f} bytes"))
|
|
|
1746
|
+ except (AttributeError, ZeroDivisionError):
|
|
|
1747
|
+ pass
|
|
|
1748
|
+
|
|
|
1749
|
+ for label, value in stats:
|
|
|
1750
|
+ self.pdf.cell(50, 8, f"{label}:", 0, 0, 'L')
|
|
|
1751
|
+ self.pdf.cell(0, 8, str(value), 0, 1, 'L')
|
|
|
1752
|
+
|
|
|
1753
|
+ self.pdf.ln(10)
|
|
|
1754
|
+
|
|
|
1755
|
+ # File extensions
|
|
|
1756
|
+ if hasattr(data, 'extensions') and data.extensions:
|
|
|
1757
|
+ self.pdf.set_font('Arial', 'B', 14)
|
|
|
1758
|
+ self.pdf.cell(0, 10, 'File Extensions', 0, 1, 'L')
|
|
|
1759
|
+
|
|
|
1760
|
+ # Table header
|
|
|
1761
|
+ self.pdf.set_font('Arial', 'B', 9)
|
|
|
1762
|
+ self.pdf.cell(25, 8, 'Extension', 1, 0, 'C')
|
|
|
1763
|
+ self.pdf.cell(20, 8, 'Files', 1, 0, 'C')
|
|
|
1764
|
+ self.pdf.cell(20, 8, '% Files', 1, 0, 'C')
|
|
|
1765
|
+ self.pdf.cell(25, 8, 'Lines', 1, 0, 'C')
|
|
|
1766
|
+ self.pdf.cell(20, 8, '% Lines', 1, 0, 'C')
|
|
|
1767
|
+ self.pdf.cell(25, 8, 'Lines/File', 1, 1, 'C')
|
|
|
1768
|
+
|
|
|
1769
|
+ # Table data - show top extensions
|
|
|
1770
|
+ self.pdf.set_font('Arial', '', 8)
|
|
|
1771
|
+ sorted_extensions = sorted(data.extensions.items(),
|
|
|
1772
|
+ key=lambda x: x[1]['files'], reverse=True)[:15]
|
|
|
1773
|
+
|
|
|
1774
|
+ for ext, ext_data in sorted_extensions:
|
|
|
1775
|
+ files = ext_data['files']
|
|
|
1776
|
+ lines = ext_data['lines']
|
|
|
1777
|
+ loc_percentage = (100.0 * lines / total_loc) if total_loc else 0.0
|
|
|
1778
|
+ files_percentage = (100.0 * files / total_files) if total_files else 0.0
|
|
|
1779
|
+ lines_per_file = (lines // files) if files else 0
|
|
|
1780
|
+
|
|
|
1781
|
+ display_ext = ext if ext else '(no ext)'
|
|
|
1782
|
+
|
|
|
1783
|
+ self.pdf.cell(25, 6, display_ext[:12], 1, 0, 'L')
|
|
|
1784
|
+ self.pdf.cell(20, 6, str(files), 1, 0, 'C')
|
|
|
1785
|
+ self.pdf.cell(20, 6, f"{files_percentage:.1f}%", 1, 0, 'C')
|
|
|
1786
|
+ self.pdf.cell(25, 6, str(lines), 1, 0, 'C')
|
|
|
1787
|
+ self.pdf.cell(20, 6, f"{loc_percentage:.1f}%", 1, 0, 'C')
|
|
|
1788
|
+ self.pdf.cell(25, 6, str(lines_per_file), 1, 1, 'C')
|
|
|
1789
|
+
|
|
|
1790
|
+ self.pdf.ln(10)
|
|
|
1791
|
+
|
|
|
1792
|
+ # Files by date chart
|
|
|
1793
|
+ self.pdf.set_font('Arial', 'B', 14)
|
|
|
1794
|
+ self.pdf.cell(0, 10, 'Files by Date', 0, 1, 'L')
|
|
|
1795
|
+ self._add_chart_if_exists('files_by_date.png', 180, 100)
|
|
|
1796
|
+
|
|
|
1797
|
+ def _create_lines_page(self, data):
|
|
|
1798
|
+ """Create the lines of code statistics page with charts (mirrors lines.html)."""
|
|
|
1799
|
+ self.pdf.add_page()
|
|
|
1800
|
+ self.pdf.set_font('Arial', 'B', 20)
|
|
|
1801
|
+ self.pdf.cell(0, 15, '5. Lines of Code Statistics', 0, 1, 'L')
|
|
|
1802
|
+
|
|
|
1803
|
+ # Basic line stats
|
|
|
1804
|
+ self.pdf.set_font('Arial', '', 12)
|
|
|
1805
|
+ stats = [
|
|
|
1806
|
+ ('Total lines', str(data.getTotalLOC())),
|
|
|
1807
|
+ ('Lines added', str(data.total_lines_added)),
|
|
|
1808
|
+ ('Lines removed', str(data.total_lines_removed)),
|
|
|
1809
|
+ ('Net lines', str(data.total_lines_added - data.total_lines_removed)),
|
|
|
1810
|
+ ]
|
|
|
1811
|
+
|
|
|
1812
|
+ for label, value in stats:
|
|
|
1813
|
+ self.pdf.cell(50, 8, f"{label}:", 0, 0, 'L')
|
|
|
1814
|
+ self.pdf.cell(0, 8, str(value), 0, 1, 'L')
|
|
|
1815
|
+
|
|
|
1816
|
+ self.pdf.ln(10)
|
|
|
1817
|
+
|
|
|
1818
|
+ # Lines by year
|
|
|
1819
|
+ if hasattr(data, 'commits_by_year') and data.commits_by_year:
|
|
|
1820
|
+ self.pdf.set_font('Arial', 'B', 14)
|
|
|
1821
|
+ self.pdf.cell(0, 10, 'Activity by Year', 0, 1, 'L')
|
|
|
1822
|
+
|
|
|
1823
|
+ # Table header
|
|
|
1824
|
+ self.pdf.set_font('Arial', 'B', 10)
|
|
|
1825
|
+ self.pdf.cell(25, 8, 'Year', 1, 0, 'C')
|
|
|
1826
|
+ self.pdf.cell(30, 8, 'Commits', 1, 0, 'C')
|
|
|
1827
|
+ self.pdf.cell(30, 8, '% of Total', 1, 0, 'C')
|
|
|
1828
|
+ self.pdf.cell(35, 8, 'Lines Added', 1, 0, 'C')
|
|
|
1829
|
+ self.pdf.cell(35, 8, 'Lines Removed', 1, 1, 'C')
|
|
|
1830
|
+
|
|
|
1831
|
+ # Table data
|
|
|
1832
|
+ self.pdf.set_font('Arial', '', 9)
|
|
|
1833
|
+ total_commits = data.getTotalCommits()
|
|
|
1834
|
+
|
|
|
1835
|
+ for yy in sorted(data.commits_by_year.keys(), reverse=True):
|
|
|
1836
|
+ commits = data.commits_by_year.get(yy, 0)
|
|
|
1837
|
+ percent = (100.0 * commits / total_commits) if total_commits else 0.0
|
|
|
1838
|
+ lines_added = data.lines_added_by_year.get(yy, 0) if hasattr(data, 'lines_added_by_year') else 0
|
|
|
1839
|
+ lines_removed = data.lines_removed_by_year.get(yy, 0) if hasattr(data, 'lines_removed_by_year') else 0
|
|
|
1840
|
+
|
|
|
1841
|
+ self.pdf.cell(25, 6, str(yy), 1, 0, 'C')
|
|
|
1842
|
+ self.pdf.cell(30, 6, str(commits), 1, 0, 'C')
|
|
|
1843
|
+ self.pdf.cell(30, 6, f"{percent:.1f}%", 1, 0, 'C')
|
|
|
1844
|
+ self.pdf.cell(35, 6, str(lines_added), 1, 0, 'C')
|
|
|
1845
|
+ self.pdf.cell(35, 6, str(lines_removed), 1, 1, 'C')
|
|
|
1846
|
+
|
|
|
1847
|
+ self.pdf.ln(10)
|
|
|
1848
|
+
|
|
|
1849
|
+ # Lines of code chart
|
|
|
1850
|
+ self.pdf.set_font('Arial', 'B', 14)
|
|
|
1851
|
+ self.pdf.cell(0, 10, 'Lines of Code Over Time', 0, 1, 'L')
|
|
|
1852
|
+ self._add_chart_if_exists('lines_of_code.png', 180, 100)
|
|
|
1853
|
+
|
|
|
1854
|
+ def _create_tags_page(self, data):
|
|
|
1855
|
+ """Create the tags statistics page (mirrors tags.html)."""
|
|
|
1856
|
+ self.pdf.add_page()
|
|
|
1857
|
+ self.pdf.set_font('Arial', 'B', 20)
|
|
|
1858
|
+ self.pdf.cell(0, 15, '6. Tags Statistics', 0, 1, 'L')
|
|
|
1859
|
+
|
|
|
1860
|
+ self.pdf.set_font('Arial', '', 12)
|
|
|
1861
|
+
|
|
|
1862
|
+ if not hasattr(data, 'tags') or not data.tags:
|
|
|
1863
|
+ self.pdf.cell(0, 10, 'No tags found in repository.', 0, 1, 'L')
|
|
|
1864
|
+ return
|
|
|
1865
|
+
|
|
|
1866
|
+ # Basic tag stats
|
|
|
1867
|
+ total_tags = len(data.tags)
|
|
|
1868
|
+ avg_commits_per_tag = (1.0 * data.getTotalCommits() / total_tags) if total_tags else 0.0
|
|
|
1869
|
+
|
|
|
1870
|
+ stats = [
|
|
|
1871
|
+ ('Total tags', str(total_tags)),
|
|
|
1872
|
+ ('Average commits per tag', f"{avg_commits_per_tag:.2f}"),
|
|
|
1873
|
+ ]
|
|
|
1874
|
+
|
|
|
1875
|
+ for label, value in stats:
|
|
|
1876
|
+ self.pdf.cell(50, 8, f"{label}:", 0, 0, 'L')
|
|
|
1877
|
+ self.pdf.cell(0, 8, str(value), 0, 1, 'L')
|
|
|
1878
|
+
|
|
|
1879
|
+ self.pdf.ln(10)
|
|
|
1880
|
+
|
|
|
1881
|
+ # Tags table
|
|
|
1882
|
+ if hasattr(data, 'tags') and data.tags:
|
|
|
1883
|
+ self.pdf.set_font('Arial', 'B', 12)
|
|
|
1884
|
+ self.pdf.cell(0, 10, 'List of Tags', 0, 1, 'L')
|
|
|
1885
|
+
|
|
|
1886
|
+ # Table header
|
|
|
1887
|
+ self.pdf.set_font('Arial', 'B', 10)
|
|
|
1888
|
+ self.pdf.cell(40, 8, 'Tag', 1, 0, 'C')
|
|
|
1889
|
+ self.pdf.cell(30, 8, 'Date', 1, 0, 'C')
|
|
|
1890
|
+ self.pdf.cell(30, 8, 'Commits', 1, 0, 'C')
|
|
|
1891
|
+ self.pdf.cell(50, 8, 'Author', 1, 1, 'C')
|
|
|
1892
|
+
|
|
|
1893
|
+ # Table data
|
|
|
1894
|
+ self.pdf.set_font('Arial', '', 9)
|
|
|
1895
|
+ tag_list = sorted(data.tags.items(), key=lambda x: x[1]['date'], reverse=True)
|
|
|
1896
|
+
|
|
|
1897
|
+ for tag, tag_data in tag_list[:20]: # Show top 20 tags
|
|
|
1898
|
+ self.pdf.cell(40, 6, tag[:20], 1, 0, 'L')
|
|
|
1899
|
+ self.pdf.cell(30, 6, tag_data.get('date', 'N/A')[:10], 1, 0, 'C')
|
|
|
1900
|
+ self.pdf.cell(30, 6, str(tag_data.get('commits', 0)), 1, 0, 'C')
|
|
|
1901
|
+ author = tag_data.get('author', 'N/A')[:25]
|
|
|
1902
|
+ self.pdf.cell(50, 6, author, 1, 1, 'L')
|
|
|
1903
|
+
|
|
|
1904
|
+ # Tags table
|
|
|
1905
|
+ self.pdf.set_font('Arial', 'B', 14)
|
|
|
1906
|
+ self.pdf.cell(0, 10, 'Recent Tags', 0, 1, 'L')
|
|
|
1907
|
+
|
|
|
1908
|
+ # Table header
|
|
|
1909
|
+ self.pdf.set_font('Arial', 'B', 10)
|
|
|
1910
|
+ self.pdf.cell(40, 8, 'Tag Name', 1, 0, 'C')
|
|
|
1911
|
+ self.pdf.cell(30, 8, 'Date', 1, 0, 'C')
|
|
|
1912
|
+ self.pdf.cell(25, 8, 'Commits', 1, 0, 'C')
|
|
|
1913
|
+ self.pdf.cell(80, 8, 'Top Authors', 1, 1, 'C')
|
|
|
1914
|
+
|
|
|
1915
|
+ # Sort tags by date (most recent first)
|
|
|
1916
|
+ tags_sorted_by_date_desc = list(map(lambda el : el[1],
|
|
|
1917
|
+ reversed(sorted(map(lambda el : (el[1]['date'], el[0]),
|
|
|
1918
|
+ data.tags.items())))))
|
|
|
1919
|
+
|
|
|
1920
|
+ # Show up to 20 most recent tags
|
|
|
1921
|
+ self.pdf.set_font('Arial', '', 8)
|
|
|
1922
|
+ for tag in tags_sorted_by_date_desc[:20]:
|
|
|
1923
|
+ tag_info = data.tags[tag]
|
|
|
1924
|
+
|
|
|
1925
|
+ # Get top authors for this tag
|
|
|
1926
|
+ if 'authors' in tag_info:
|
|
|
1927
|
+ authors = sorted(tag_info['authors'].items(),
|
|
|
1928
|
+ key=lambda x: x[1], reverse=True)[:3]
|
|
|
1929
|
+ author_list = ', '.join([f"{author}({commits})" for author, commits in authors])
|
|
|
1930
|
+ else:
|
|
|
1931
|
+ author_list = ''
|
|
|
1932
|
+
|
|
|
1933
|
+ # Truncate long names
|
|
|
1934
|
+ display_tag = tag[:18] + "..." if len(tag) > 21 else tag
|
|
|
1935
|
+ display_authors = author_list[:35] + "..." if len(author_list) > 38 else author_list
|
|
|
1936
|
+
|
|
|
1937
|
+ self.pdf.cell(40, 6, display_tag, 1, 0, 'L')
|
|
|
1938
|
+ self.pdf.cell(30, 6, tag_info['date'][:10], 1, 0, 'C')
|
|
|
1939
|
+ self.pdf.cell(25, 6, str(tag_info['commits']), 1, 0, 'C')
|
|
|
1940
|
+ self.pdf.cell(80, 6, display_authors, 1, 1, 'L')
|
|
|
1941
|
+
|
|
1422
|
1942
|
|
|
1423
|
1943
|
def usage():
|
|
1424
|
1944
|
print("""
|
|
|
@@ -1427,6 +1947,16 @@ Usage: gitstats [options] <gitpath..> <outputpath>
|
|
1427
|
1947
|
Options:
|
|
1428
|
1948
|
-c key=value Override configuration value
|
|
1429
|
1949
|
|
|
|
1950
|
+Available output formats (use -c output_format=<format>):
|
|
|
1951
|
+ html Generate HTML report only (default)
|
|
|
1952
|
+ pdf Generate PDF report only
|
|
|
1953
|
+ both Generate both HTML and PDF reports
|
|
|
1954
|
+
|
|
|
1955
|
+Examples:
|
|
|
1956
|
+ gitstats repo output # HTML report
|
|
|
1957
|
+ gitstats -c output_format=pdf repo output # PDF report
|
|
|
1958
|
+ gitstats -c output_format=both repo output # Both formats
|
|
|
1959
|
+
|
|
1430
|
1960
|
Default config values:
|
|
1431
|
1961
|
%s
|
|
1432
|
1962
|
|
|
|
@@ -1493,16 +2023,30 @@ class GitStats:
|
|
1493
|
2023
|
os.chdir(rundir)
|
|
1494
|
2024
|
|
|
1495
|
2025
|
print('Generating report...')
|
|
1496
|
|
- report = HTMLReportCreator()
|
|
1497
|
|
- report.create(data, outputpath)
|
|
|
2026
|
+
|
|
|
2027
|
+ # Generate reports based on output_format configuration
|
|
|
2028
|
+ if conf['output_format'] in ('html', 'both'):
|
|
|
2029
|
+ print('Creating HTML report...')
|
|
|
2030
|
+ html_report = HTMLReportCreator()
|
|
|
2031
|
+ html_report.create(data, outputpath)
|
|
|
2032
|
+
|
|
|
2033
|
+ if conf['output_format'] in ('pdf', 'both'):
|
|
|
2034
|
+ print('Creating PDF report...')
|
|
|
2035
|
+ pdf_report = PDFReportCreator()
|
|
|
2036
|
+ pdf_report.create(data, outputpath)
|
|
1498
|
2037
|
|
|
1499
|
2038
|
time_end = time.time()
|
|
1500
|
2039
|
exectime_internal = time_end - time_start
|
|
1501
|
2040
|
print('Execution time %.5f secs, %.5f secs (%.2f %%) in external commands)' % (exectime_internal, exectime_external, (100.0 * exectime_external) / exectime_internal))
|
|
|
2041
|
+
|
|
1502
|
2042
|
if sys.stdin.isatty():
|
|
1503
|
2043
|
print('You may now run:')
|
|
1504
|
2044
|
print()
|
|
1505
|
|
- print(' sensible-browser \'%s\'' % os.path.join(outputpath, 'index.html').replace("'", "'\\''"))
|
|
|
2045
|
+ if conf['output_format'] in ('html', 'both'):
|
|
|
2046
|
+ print(' sensible-browser \'%s\'' % os.path.join(outputpath, 'index.html').replace("'", "'\\''"))
|
|
|
2047
|
+ if conf['output_format'] in ('pdf', 'both'):
|
|
|
2048
|
+ pdf_filename = f"gitstats_{data.projectname.replace(' ', '_')}.pdf"
|
|
|
2049
|
+ print(' PDF report: \'%s\'' % os.path.join(outputpath, pdf_filename).replace("'", "'\\''"))
|
|
1506
|
2050
|
print()
|
|
1507
|
2051
|
|
|
1508
|
2052
|
if __name__=='__main__':
|