gitlytics 0.1.2__tar.gz → 0.1.3__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {gitlytics-0.1.2 → gitlytics-0.1.3}/PKG-INFO +15 -10
- {gitlytics-0.1.2 → gitlytics-0.1.3}/README.md +2 -2
- gitlytics-0.1.3/pyproject.toml +74 -0
- gitlytics-0.1.3/src/gitlytics/__init__.py +140 -0
- gitlytics-0.1.3/src/gitlytics/__main__.py +9 -0
- gitlytics-0.1.3/src/gitlytics/api.py +177 -0
- gitlytics-0.1.3/src/gitlytics/automation.py +217 -0
- {gitlytics-0.1.2 → gitlytics-0.1.3}/src/gitlytics/cli.py +35 -7
- gitlytics-0.1.3/src/gitlytics/core.py +288 -0
- gitlytics-0.1.3/src/gitlytics/process.py +237 -0
- gitlytics-0.1.2/src/gitlytics/static/assets/index-BPCIBQz4.js → gitlytics-0.1.3/src/gitlytics/static/assets/index-B5LHR_NK.js +3 -3
- gitlytics-0.1.3/src/gitlytics/static/assets/index-Cbu2tSV_.css +2 -0
- {gitlytics-0.1.2 → gitlytics-0.1.3}/src/gitlytics/static/index.html +2 -2
- {gitlytics-0.1.2 → gitlytics-0.1.3}/src/gitlytics.egg-info/PKG-INFO +15 -10
- {gitlytics-0.1.2 → gitlytics-0.1.3}/src/gitlytics.egg-info/SOURCES.txt +6 -3
- gitlytics-0.1.3/src/gitlytics.egg-info/requires.txt +16 -0
- gitlytics-0.1.3/tests/test_api.py +264 -0
- gitlytics-0.1.3/tests/test_automation.py +178 -0
- gitlytics-0.1.3/tests/test_cli.py +130 -0
- gitlytics-0.1.3/tests/test_core.py +226 -0
- gitlytics-0.1.3/tests/test_process.py +321 -0
- gitlytics-0.1.2/pyproject.toml +0 -52
- gitlytics-0.1.2/src/gitlytics/__init__.py +0 -73
- gitlytics-0.1.2/src/gitlytics/api.py +0 -112
- gitlytics-0.1.2/src/gitlytics/automation.py +0 -153
- gitlytics-0.1.2/src/gitlytics/core.py +0 -188
- gitlytics-0.1.2/src/gitlytics/process.py +0 -117
- gitlytics-0.1.2/src/gitlytics/static/assets/index-DytQw1pB.css +0 -2
- gitlytics-0.1.2/src/gitlytics.egg-info/requires.txt +0 -13
- gitlytics-0.1.2/tests/test_automation.py +0 -72
- gitlytics-0.1.2/tests/test_cli.py +0 -56
- gitlytics-0.1.2/tests/test_core.py +0 -55
- {gitlytics-0.1.2 → gitlytics-0.1.3}/LICENSE +0 -0
- {gitlytics-0.1.2 → gitlytics-0.1.3}/setup.cfg +0 -0
- {gitlytics-0.1.2 → gitlytics-0.1.3}/src/gitlytics/static/favicon.svg +0 -0
- {gitlytics-0.1.2 → gitlytics-0.1.3}/src/gitlytics/static/icons.svg +0 -0
- {gitlytics-0.1.2 → gitlytics-0.1.3}/src/gitlytics.egg-info/dependency_links.txt +0 -0
- {gitlytics-0.1.2 → gitlytics-0.1.3}/src/gitlytics.egg-info/entry_points.txt +0 -0
- {gitlytics-0.1.2 → gitlytics-0.1.3}/src/gitlytics.egg-info/top_level.txt +0 -0
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: gitlytics
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.3
|
|
4
4
|
Summary: Monitor and automate your GitHub repository traffic analytics.
|
|
5
5
|
Author: Ameya Chopade
|
|
6
6
|
License: Apache-2.0
|
|
7
7
|
Project-URL: Homepage, https://github.com/ameyac11/gitlytics
|
|
8
|
+
Project-URL: Repository, https://github.com/ameyac11/gitlytics
|
|
9
|
+
Project-URL: Bug Tracker, https://github.com/ameyac11/gitlytics/issues
|
|
8
10
|
Keywords: github,traffic,analytics,automation,cli
|
|
9
11
|
Classifier: Programming Language :: Python :: 3
|
|
10
12
|
Classifier: License :: OSI Approved :: Apache Software License
|
|
@@ -12,17 +14,20 @@ Classifier: Operating System :: OS Independent
|
|
|
12
14
|
Requires-Python: >=3.9
|
|
13
15
|
Description-Content-Type: text/markdown
|
|
14
16
|
License-File: LICENSE
|
|
15
|
-
Requires-Dist: requests
|
|
16
|
-
Requires-Dist: pandas
|
|
17
|
-
Requires-Dist: python-dotenv
|
|
18
|
-
Requires-Dist: croniter
|
|
17
|
+
Requires-Dist: requests>=2.32.0
|
|
18
|
+
Requires-Dist: pandas>=2.2.0
|
|
19
|
+
Requires-Dist: python-dotenv>=1.0.1
|
|
20
|
+
Requires-Dist: croniter>=2.0.0
|
|
19
21
|
Provides-Extra: dashboard
|
|
20
|
-
Requires-Dist: fastapi; extra == "dashboard"
|
|
21
|
-
Requires-Dist: uvicorn; extra == "dashboard"
|
|
22
|
-
Requires-Dist: python-multipart; extra == "dashboard"
|
|
22
|
+
Requires-Dist: fastapi>=0.111.0; extra == "dashboard"
|
|
23
|
+
Requires-Dist: uvicorn>=0.30.0; extra == "dashboard"
|
|
24
|
+
Requires-Dist: python-multipart>=0.0.9; extra == "dashboard"
|
|
23
25
|
Provides-Extra: dev
|
|
24
26
|
Requires-Dist: pytest>=8.0.0; extra == "dev"
|
|
25
27
|
Requires-Dist: pytest-cov>=5.0.0; extra == "dev"
|
|
28
|
+
Requires-Dist: httpx>=0.27.0; extra == "dev"
|
|
29
|
+
Requires-Dist: Faker>=20.0.0; extra == "dev"
|
|
30
|
+
Requires-Dist: anyio[trio]>=4.0.0; extra == "dev"
|
|
26
31
|
Dynamic: license-file
|
|
27
32
|
|
|
28
33
|
<div align="center">
|
|
@@ -159,7 +164,7 @@ import gitlytics
|
|
|
159
164
|
# Fetch traffic for all repositories accessible by the token
|
|
160
165
|
df = gitlytics.fetch_traffic(
|
|
161
166
|
token="ghp_your_token",
|
|
162
|
-
return_format="dataframe" # Options: "dataframe" (Pandas), "timeseries" (
|
|
167
|
+
return_format="dataframe" # Options: "dataframe" (Pandas), "timeseries" (chart-ready dict), or "summary" (per-repo totals dict)
|
|
163
168
|
)
|
|
164
169
|
|
|
165
170
|
# Fetch traffic for a single specific repository and print the table to stdout
|
|
@@ -183,7 +188,7 @@ gitlytics.fetch_traffic(
|
|
|
183
188
|
| `token` | `str` | *Required* | GitHub Personal Access Token with `repo` scope enabled. |
|
|
184
189
|
| `repo_name` | `str` | `None` | Specific repository name (e.g. `"user/repo"`). If `None`, fetches all repositories. |
|
|
185
190
|
| `print_table` | `bool` | `False` | If `True`, formats and prints a detailed ASCII traffic table to the console. |
|
|
186
|
-
| `return_format` | `str` | `"dataframe"` | The format of returned data: `"dataframe"` (Pandas DataFrame), `"timeseries"
|
|
191
|
+
| `return_format` | `str` | `"dataframe"` | The format of returned data: `"dataframe"` (Pandas DataFrame), `"timeseries"` (chart-ready nested dict), or `"summary"` (per-repo totals dict). |
|
|
187
192
|
| `save_file` | `str` | `None` | Optional. File path where the fetched data will be saved (CSV or JSON). |
|
|
188
193
|
|
|
189
194
|
---
|
|
@@ -132,7 +132,7 @@ import gitlytics
|
|
|
132
132
|
# Fetch traffic for all repositories accessible by the token
|
|
133
133
|
df = gitlytics.fetch_traffic(
|
|
134
134
|
token="ghp_your_token",
|
|
135
|
-
return_format="dataframe" # Options: "dataframe" (Pandas), "timeseries" (
|
|
135
|
+
return_format="dataframe" # Options: "dataframe" (Pandas), "timeseries" (chart-ready dict), or "summary" (per-repo totals dict)
|
|
136
136
|
)
|
|
137
137
|
|
|
138
138
|
# Fetch traffic for a single specific repository and print the table to stdout
|
|
@@ -156,7 +156,7 @@ gitlytics.fetch_traffic(
|
|
|
156
156
|
| `token` | `str` | *Required* | GitHub Personal Access Token with `repo` scope enabled. |
|
|
157
157
|
| `repo_name` | `str` | `None` | Specific repository name (e.g. `"user/repo"`). If `None`, fetches all repositories. |
|
|
158
158
|
| `print_table` | `bool` | `False` | If `True`, formats and prints a detailed ASCII traffic table to the console. |
|
|
159
|
-
| `return_format` | `str` | `"dataframe"` | The format of returned data: `"dataframe"` (Pandas DataFrame), `"timeseries"
|
|
159
|
+
| `return_format` | `str` | `"dataframe"` | The format of returned data: `"dataframe"` (Pandas DataFrame), `"timeseries"` (chart-ready nested dict), or `"summary"` (per-repo totals dict). |
|
|
160
160
|
| `save_file` | `str` | `None` | Optional. File path where the fetched data will be saved (CSV or JSON). |
|
|
161
161
|
|
|
162
162
|
---
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61.0"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "gitlytics"
|
|
7
|
+
version = "0.1.3"
|
|
8
|
+
description = "Monitor and automate your GitHub repository traffic analytics."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = { text = "Apache-2.0" }
|
|
12
|
+
authors = [
|
|
13
|
+
{ name = "Ameya Chopade" }
|
|
14
|
+
]
|
|
15
|
+
# Fix #20: Add version lower-bounds to prevent silent breakage on old environments.
|
|
16
|
+
# These match the constraints already documented in requirements.txt.
|
|
17
|
+
dependencies = [
|
|
18
|
+
"requests>=2.32.0",
|
|
19
|
+
"pandas>=2.2.0",
|
|
20
|
+
"python-dotenv>=1.0.1",
|
|
21
|
+
"croniter>=2.0.0"
|
|
22
|
+
]
|
|
23
|
+
keywords = [
|
|
24
|
+
"github",
|
|
25
|
+
"traffic",
|
|
26
|
+
"analytics",
|
|
27
|
+
"automation",
|
|
28
|
+
"cli"
|
|
29
|
+
]
|
|
30
|
+
classifiers = [
|
|
31
|
+
"Programming Language :: Python :: 3",
|
|
32
|
+
"License :: OSI Approved :: Apache Software License",
|
|
33
|
+
"Operating System :: OS Independent",
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
[project.urls]
|
|
37
|
+
Homepage = "https://github.com/ameyac11/gitlytics"
|
|
38
|
+
Repository = "https://github.com/ameyac11/gitlytics"
|
|
39
|
+
"Bug Tracker" = "https://github.com/ameyac11/gitlytics/issues"
|
|
40
|
+
|
|
41
|
+
[project.optional-dependencies]
|
|
42
|
+
# Fix #20: dashboard extras are optional — base package works without them.
|
|
43
|
+
# requirements.txt was including them as mandatory; that was wrong.
|
|
44
|
+
dashboard = [
|
|
45
|
+
"fastapi>=0.111.0",
|
|
46
|
+
"uvicorn>=0.30.0",
|
|
47
|
+
"python-multipart>=0.0.9"
|
|
48
|
+
]
|
|
49
|
+
dev = [
|
|
50
|
+
"pytest>=8.0.0",
|
|
51
|
+
"pytest-cov>=5.0.0",
|
|
52
|
+
"httpx>=0.27.0", # required by FastAPI TestClient (used in test_api.py)
|
|
53
|
+
"Faker>=20.0.0", # used by pytest-faker plugin for generating test data
|
|
54
|
+
"anyio[trio]>=4.0.0" # async test support
|
|
55
|
+
]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
[project.scripts]
|
|
59
|
+
gitlytics = "gitlytics.cli:main"
|
|
60
|
+
|
|
61
|
+
[tool.setuptools.packages.find]
|
|
62
|
+
where = ["src"]
|
|
63
|
+
include = ["gitlytics*"]
|
|
64
|
+
|
|
65
|
+
[tool.setuptools.package-data]
|
|
66
|
+
"gitlytics" = ["static/**/*", "static/*"]
|
|
67
|
+
|
|
68
|
+
[tool.pytest.ini_options]
|
|
69
|
+
# Only collect files that match the offline test naming convention.
|
|
70
|
+
# live_*.py scripts are excluded — they need a real GitHub token.
|
|
71
|
+
testpaths = ["tests"]
|
|
72
|
+
python_files = ["test_*.py"]
|
|
73
|
+
python_classes = ["Test*"]
|
|
74
|
+
python_functions = ["test_*"]
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""
|
|
2
|
+
gitlytics/__init__.py
|
|
3
|
+
The public API for the gitlytics package.
|
|
4
|
+
"""
|
|
5
|
+
import os
|
|
6
|
+
import logging
|
|
7
|
+
import json
|
|
8
|
+
|
|
9
|
+
# Single source of truth for the package version.
|
|
10
|
+
# Mirrors the version in pyproject.toml — keep them in sync.
|
|
11
|
+
__version__ = "0.1.3"
|
|
12
|
+
|
|
13
|
+
# Import the internal building blocks — users never call these directly
|
|
14
|
+
from .core import fetch_traffic_data, print_repo_table
|
|
15
|
+
from .automation import run_sync
|
|
16
|
+
from .process import build_json_payload
|
|
17
|
+
|
|
18
|
+
# Set up a silent logger so gitlytics never messes with your app's logging
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
logger.addHandler(logging.NullHandler())
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def fetch_traffic(token: str, repo_name=None, print_table: bool = False, return_format: str = "dataframe", save_file: str = None):
|
|
24
|
+
"""
|
|
25
|
+
Fetches the last 14 days of traffic data for one or all repositories.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
token: GitHub Personal Access Token with `repo` scope.
|
|
29
|
+
repo_name: Specific repo name (e.g. ``"user/repo"``) or list of names.
|
|
30
|
+
If ``None``, fetches all repositories accessible by the token.
|
|
31
|
+
print_table: If ``True``, prints an ASCII summary table to stdout.
|
|
32
|
+
return_format: Shape of the returned data. One of:
|
|
33
|
+
``"dataframe"`` (default) — returns a ``pandas.DataFrame``.
|
|
34
|
+
``"timeseries"`` — returns a nested JSON-serialisable dict.
|
|
35
|
+
``"summary"`` — returns a per-repo totals dict.
|
|
36
|
+
save_file: Optional path to save the output. Extension determines
|
|
37
|
+
format: ``.json`` writes JSON, anything else writes CSV.
|
|
38
|
+
|
|
39
|
+
Returns:
|
|
40
|
+
A ``pandas.DataFrame`` when ``return_format="dataframe"``, otherwise
|
|
41
|
+
a ``dict`` matching the requested format.
|
|
42
|
+
"""
|
|
43
|
+
# Hit the GitHub API and get back a tidy DataFrame (one row per day per repo)
|
|
44
|
+
df = fetch_traffic_data(token, repo_name)
|
|
45
|
+
|
|
46
|
+
# Print the ASCII table to the console if the user asked for it
|
|
47
|
+
if print_table:
|
|
48
|
+
print_repo_table(df)
|
|
49
|
+
|
|
50
|
+
# --- dataframe mode: just return the raw DataFrame, optionally save it ---
|
|
51
|
+
if return_format == "dataframe":
|
|
52
|
+
if save_file:
|
|
53
|
+
if save_file.endswith(".json"):
|
|
54
|
+
# Save as a chart-ready JSON file
|
|
55
|
+
payload = build_json_payload(df, return_format="timeseries", export_public_only=True)
|
|
56
|
+
with open(save_file, "w", encoding="utf-8") as f:
|
|
57
|
+
json.dump(payload, f, indent=2)
|
|
58
|
+
else:
|
|
59
|
+
# Save as a standard CSV file
|
|
60
|
+
df.to_csv(save_file, index=False)
|
|
61
|
+
return df
|
|
62
|
+
|
|
63
|
+
# Reject anything that isn't a known format before doing more work
|
|
64
|
+
valid_formats = {"timeseries", "summary"}
|
|
65
|
+
if return_format not in valid_formats:
|
|
66
|
+
raise ValueError(
|
|
67
|
+
f"Invalid return_format={return_format!r}. "
|
|
68
|
+
f"Choose one of: 'dataframe', 'timeseries', 'summary'."
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
# Build the JSON-serialisable payload in the requested shape
|
|
72
|
+
payload = build_json_payload(df, return_format=return_format, export_public_only=False)
|
|
73
|
+
|
|
74
|
+
# Save to disk if the user gave us a file path
|
|
75
|
+
if save_file:
|
|
76
|
+
with open(save_file, "w", encoding="utf-8") as f:
|
|
77
|
+
json.dump(payload, f, indent=2)
|
|
78
|
+
|
|
79
|
+
return payload
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def sync(token: str, repo_name=None, data_dir: str = "./data", output_mode: str = "monthly", schedule_cron: str = None, export_json: str = None, export_public_only: bool = True):
|
|
83
|
+
"""
|
|
84
|
+
Fetches data and appends it to a local CSV database, optionally running as a permanent background daemon.
|
|
85
|
+
|
|
86
|
+
Args:
|
|
87
|
+
token: GitHub Personal Access Token.
|
|
88
|
+
repo_name: Specific repository name(s) to sync. If ``None``, syncs all.
|
|
89
|
+
data_dir: Directory where CSV files are stored.
|
|
90
|
+
output_mode: ``"monthly"`` (``traffic_YYYY-MM.csv``) or ``"yearly"`` (``traffic_YYYY.csv``).
|
|
91
|
+
schedule_cron: Standard cron expression (e.g. ``"0 23 * * *"``). If set,
|
|
92
|
+
runs an infinite scheduler loop.
|
|
93
|
+
export_json: Path to export the merged historical database as a JSON file.
|
|
94
|
+
export_public_only: If ``True`` (default), strips private repos from the
|
|
95
|
+
exported JSON — acts as a security firewall.
|
|
96
|
+
"""
|
|
97
|
+
# Hand off to the automation engine — it handles deduplication and schema migration
|
|
98
|
+
run_sync(
|
|
99
|
+
token=token,
|
|
100
|
+
repo_names=repo_name,
|
|
101
|
+
data_dir=data_dir,
|
|
102
|
+
output_mode=output_mode,
|
|
103
|
+
schedule_cron=schedule_cron,
|
|
104
|
+
export_json=export_json,
|
|
105
|
+
export_public_only=export_public_only
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def serve_dashboard(host: str = "127.0.0.1", port: int = 8000, token: str = None, data_dir: str = None):
|
|
110
|
+
"""
|
|
111
|
+
Starts the React + FastAPI dashboard server.
|
|
112
|
+
|
|
113
|
+
``uvicorn`` and ``fastapi`` are optional dependencies installed via::
|
|
114
|
+
|
|
115
|
+
pip install "gitlytics[dashboard]"
|
|
116
|
+
|
|
117
|
+
Args:
|
|
118
|
+
host: Host IP to bind. Use ``"0.0.0.0"`` to listen on all interfaces.
|
|
119
|
+
port: Port number (default ``8000``).
|
|
120
|
+
token: Optional GitHub token — pre-authenticates the dashboard session.
|
|
121
|
+
data_dir: Optional path to the historical CSV database directory.
|
|
122
|
+
"""
|
|
123
|
+
# Only import uvicorn when the user actually calls serve_dashboard,
|
|
124
|
+
# so the base `pip install gitlytics` doesn't crash without it
|
|
125
|
+
try:
|
|
126
|
+
import uvicorn
|
|
127
|
+
except ImportError:
|
|
128
|
+
raise ImportError(
|
|
129
|
+
"The dashboard requires additional dependencies. "
|
|
130
|
+
"Install them with: pip install \"gitlytics[dashboard]\""
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
# Pass the token and data folder to the FastAPI app via environment variables
|
|
134
|
+
if token:
|
|
135
|
+
os.environ["GITLYTICS_TOKEN"] = token
|
|
136
|
+
if data_dir:
|
|
137
|
+
os.environ["GITLYTICS_DATA_DIR"] = os.path.abspath(data_dir)
|
|
138
|
+
|
|
139
|
+
# Start the web server — it won't return until the user presses Ctrl+C
|
|
140
|
+
uvicorn.run("gitlytics.api:app", host=host, port=port, reload=False)
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""
|
|
2
|
+
gitlytics/__main__.py
|
|
3
|
+
Makes `python -m gitlytics` work identically to the `gitlytics` console command.
|
|
4
|
+
This is the entry point Python calls when the package is run with -m.
|
|
5
|
+
"""
|
|
6
|
+
from gitlytics.cli import main
|
|
7
|
+
|
|
8
|
+
# Run the CLI when invoked as `python -m gitlytics`
|
|
9
|
+
main()
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"""
|
|
2
|
+
gitlytics/api.py
|
|
3
|
+
Powers the FastAPI backend — serves traffic data and the React dashboard to the browser.
|
|
4
|
+
"""
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import pandas as pd
|
|
10
|
+
from fastapi import FastAPI, HTTPException, Body, File, UploadFile
|
|
11
|
+
from fastapi.responses import FileResponse, JSONResponse
|
|
12
|
+
from fastapi.middleware.cors import CORSMiddleware
|
|
13
|
+
from fastapi.staticfiles import StaticFiles
|
|
14
|
+
|
|
15
|
+
from gitlytics.core import validate_token, get_user_profile, fetch_traffic_data
|
|
16
|
+
from gitlytics.process import process_uploaded_csv, build_react_payload
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
app = FastAPI(title="GitHub Traffic API")
|
|
21
|
+
|
|
22
|
+
# Only allow requests from localhost — this dashboard is never deployed publicly
|
|
23
|
+
_ALLOWED_ORIGINS = [
|
|
24
|
+
"http://localhost",
|
|
25
|
+
"http://localhost:3000",
|
|
26
|
+
"http://localhost:5173",
|
|
27
|
+
"http://localhost:8000",
|
|
28
|
+
"http://127.0.0.1",
|
|
29
|
+
"http://127.0.0.1:3000",
|
|
30
|
+
"http://127.0.0.1:5173",
|
|
31
|
+
"http://127.0.0.1:8000",
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
app.add_middleware(
|
|
35
|
+
CORSMiddleware,
|
|
36
|
+
allow_origins=_ALLOWED_ORIGINS,
|
|
37
|
+
allow_credentials=True,
|
|
38
|
+
allow_methods=["GET", "POST"],
|
|
39
|
+
allow_headers=["Content-Type"],
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _get_token(token: str = None) -> str:
|
|
44
|
+
# Use the token from the request body, or fall back to the one set in the environment
|
|
45
|
+
return token or os.environ.get("GITLYTICS_TOKEN")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@app.get("/api/config")
|
|
49
|
+
def get_config():
|
|
50
|
+
# Lets the frontend know if it's running in headless/TV mode with a pre-set token
|
|
51
|
+
return {
|
|
52
|
+
"has_token": bool(os.environ.get("GITLYTICS_TOKEN")),
|
|
53
|
+
"has_data_dir": bool(os.environ.get("GITLYTICS_DATA_DIR"))
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@app.post("/api/auth")
|
|
58
|
+
def auth(token: str = Body("", embed=True)):
|
|
59
|
+
# Validate the token and return the user's GitHub profile info
|
|
60
|
+
active_token = _get_token(token)
|
|
61
|
+
if not active_token:
|
|
62
|
+
raise HTTPException(status_code=401, detail="No token provided and no environment token found.")
|
|
63
|
+
|
|
64
|
+
ok, username = validate_token(active_token)
|
|
65
|
+
if not ok:
|
|
66
|
+
# Log a warning without echoing the token value into logs
|
|
67
|
+
logger.warning("Authentication attempt failed for a provided token.")
|
|
68
|
+
raise HTTPException(status_code=401, detail=username)
|
|
69
|
+
|
|
70
|
+
# Fetch the real display name and avatar URL — validate_token only gives us the login
|
|
71
|
+
profile = get_user_profile(active_token)
|
|
72
|
+
|
|
73
|
+
return {
|
|
74
|
+
"authenticated": True,
|
|
75
|
+
"username": profile["login"] or username,
|
|
76
|
+
"name": profile["name"] or username, # Real display name, e.g. "Ameya Chopade"
|
|
77
|
+
"avatar_url": profile["avatar_url"], # Real GitHub avatar URL
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@app.post("/api/traffic")
|
|
82
|
+
def get_traffic(token: str = Body("", embed=True)):
|
|
83
|
+
# Serve traffic data — either from the historical CSV database or live from GitHub
|
|
84
|
+
active_token = _get_token(token)
|
|
85
|
+
if not active_token:
|
|
86
|
+
raise HTTPException(status_code=401, detail="No token provided")
|
|
87
|
+
|
|
88
|
+
ok, _ = validate_token(active_token)
|
|
89
|
+
if not ok:
|
|
90
|
+
raise HTTPException(status_code=401, detail="Invalid token")
|
|
91
|
+
|
|
92
|
+
data_dir = os.environ.get("GITLYTICS_DATA_DIR")
|
|
93
|
+
if data_dir:
|
|
94
|
+
# Load from the historical CSV database (headless/TV mode)
|
|
95
|
+
data_dir_path = Path(data_dir)
|
|
96
|
+
csv_files = list(data_dir_path.glob("traffic_*.csv")) if data_dir_path.exists() else []
|
|
97
|
+
dfs = []
|
|
98
|
+
for f in csv_files:
|
|
99
|
+
try:
|
|
100
|
+
dfs.append(pd.read_csv(f))
|
|
101
|
+
except Exception as exc:
|
|
102
|
+
logger.warning(f"Skipping unreadable CSV '{f}': {exc}")
|
|
103
|
+
if dfs:
|
|
104
|
+
df = pd.concat(dfs, ignore_index=True)
|
|
105
|
+
# Clean up any duplicate day-repo rows that crept in somehow
|
|
106
|
+
df = df.drop_duplicates(subset=["date", "repository"], keep="last")
|
|
107
|
+
else:
|
|
108
|
+
# No CSVs found — fall through to a live fetch
|
|
109
|
+
df = fetch_traffic_data(active_token)
|
|
110
|
+
else:
|
|
111
|
+
# Default: hit GitHub and get the live 14-day window
|
|
112
|
+
df = fetch_traffic_data(active_token)
|
|
113
|
+
|
|
114
|
+
# Replace any infinity or NaN values before JSON serialisation
|
|
115
|
+
df = df.replace([float('inf'), float('-inf')], None).where(pd.notnull(df), None)
|
|
116
|
+
|
|
117
|
+
# Transform the DataFrame into the array of objects the React app expects
|
|
118
|
+
payload = build_react_payload(df)
|
|
119
|
+
return payload
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@app.post("/api/upload-csv")
|
|
123
|
+
def upload_csv(file: UploadFile = File(...)):
|
|
124
|
+
# Accept a user-uploaded CSV and convert it to the same format as the API response
|
|
125
|
+
try:
|
|
126
|
+
df = process_uploaded_csv(file.file)
|
|
127
|
+
df = df.replace([float('inf'), float('-inf')], None).where(pd.notnull(df), None)
|
|
128
|
+
payload = build_react_payload(df)
|
|
129
|
+
return payload
|
|
130
|
+
except Exception as e:
|
|
131
|
+
raise HTTPException(status_code=400, detail=str(e))
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
# ── Static file serving ───────────────────────────────────────────────────────
|
|
135
|
+
# The React build output lands in gitlytics/static/ after `npm run build`
|
|
136
|
+
frontend_dir = Path(__file__).parent / "static"
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
@app.get("/")
|
|
140
|
+
def serve_index():
|
|
141
|
+
# Serve the React app's index.html for the root URL
|
|
142
|
+
index_file = frontend_dir / "index.html"
|
|
143
|
+
if index_file.exists():
|
|
144
|
+
return FileResponse(index_file)
|
|
145
|
+
return JSONResponse(
|
|
146
|
+
status_code=503,
|
|
147
|
+
content={"error": "Frontend not found. Run 'npm run build' in the frontend directory."}
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
@app.get("/{full_path:path}")
|
|
152
|
+
def serve_spa_fallback(full_path: str):
|
|
153
|
+
"""
|
|
154
|
+
SPA catch-all — any URL that doesn't match an API route returns index.html
|
|
155
|
+
so React Router can handle client-side navigation on hard refresh.
|
|
156
|
+
Real static assets (JS/CSS) are served by the StaticFiles mount first.
|
|
157
|
+
"""
|
|
158
|
+
# Serve the actual file if it exists (e.g. a JS or CSS asset)
|
|
159
|
+
asset_file = frontend_dir / full_path
|
|
160
|
+
if asset_file.exists() and asset_file.is_file():
|
|
161
|
+
return FileResponse(asset_file)
|
|
162
|
+
|
|
163
|
+
# For everything else (like /repos/my-repo), hand control to React Router
|
|
164
|
+
index_file = frontend_dir / "index.html"
|
|
165
|
+
if index_file.exists():
|
|
166
|
+
return FileResponse(index_file)
|
|
167
|
+
|
|
168
|
+
return JSONResponse(
|
|
169
|
+
status_code=503,
|
|
170
|
+
content={"error": "Frontend not found. Run 'npm run build' in the frontend directory."}
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
# Mount the /assets directory for compiled JS and CSS — must come after route definitions
|
|
175
|
+
assets_dir = frontend_dir / "assets"
|
|
176
|
+
if assets_dir.exists():
|
|
177
|
+
app.mount("/assets", StaticFiles(directory=assets_dir), name="assets")
|