gitlytics 0.1.6__tar.gz → 0.2.0__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.
Files changed (52) hide show
  1. {gitlytics-0.1.6 → gitlytics-0.2.0}/PKG-INFO +2 -2
  2. {gitlytics-0.1.6 → gitlytics-0.2.0}/README.md +1 -1
  3. {gitlytics-0.1.6 → gitlytics-0.2.0}/pyproject.toml +1 -1
  4. {gitlytics-0.1.6 → gitlytics-0.2.0}/src/gitlytics/__init__.py +17 -2
  5. {gitlytics-0.1.6 → gitlytics-0.2.0}/src/gitlytics/api.py +40 -15
  6. {gitlytics-0.1.6 → gitlytics-0.2.0}/src/gitlytics/automation.py +4 -2
  7. {gitlytics-0.1.6 → gitlytics-0.2.0}/src/gitlytics/core.py +31 -8
  8. {gitlytics-0.1.6 → gitlytics-0.2.0}/src/gitlytics/process.py +20 -15
  9. gitlytics-0.2.0/src/gitlytics/static/assets/html2canvas-pro.esm-9xys3ejh.js +6 -0
  10. gitlytics-0.2.0/src/gitlytics/static/assets/html2canvas.esm-DXEQVQnt.js +5 -0
  11. gitlytics-0.2.0/src/gitlytics/static/assets/index-Cx6oOScf.js +87 -0
  12. gitlytics-0.2.0/src/gitlytics/static/assets/index-DxtMptVs.css +1 -0
  13. gitlytics-0.1.6/src/gitlytics/static/assets/index.es-BM2mGRzK.js → gitlytics-0.2.0/src/gitlytics/static/assets/index.es-DZq7ceO3.js +2 -15
  14. gitlytics-0.2.0/src/gitlytics/static/assets/jspdf.es.min-CaU6ZJCD.js +79 -0
  15. gitlytics-0.1.6/src/gitlytics/static/assets/purify.es-Csrj9YNg.js → gitlytics-0.2.0/src/gitlytics/static/assets/purify.es-CC4Brkr_.js +1 -1
  16. gitlytics-0.2.0/src/gitlytics/static/favicon-48x48.png +0 -0
  17. gitlytics-0.2.0/src/gitlytics/static/google-search-icon-48x48.png +0 -0
  18. gitlytics-0.2.0/src/gitlytics/static/google-search-icon.png +0 -0
  19. gitlytics-0.2.0/src/gitlytics/static/index.html +24 -0
  20. gitlytics-0.2.0/src/gitlytics/static/robots.txt +5 -0
  21. {gitlytics-0.1.6 → gitlytics-0.2.0}/src/gitlytics/static/sitemap.xml +5 -0
  22. {gitlytics-0.1.6 → gitlytics-0.2.0}/src/gitlytics.egg-info/PKG-INFO +2 -2
  23. {gitlytics-0.1.6 → gitlytics-0.2.0}/src/gitlytics.egg-info/SOURCES.txt +10 -8
  24. {gitlytics-0.1.6 → gitlytics-0.2.0}/tests/test_api.py +80 -0
  25. {gitlytics-0.1.6 → gitlytics-0.2.0}/tests/test_automation.py +23 -0
  26. {gitlytics-0.1.6 → gitlytics-0.2.0}/tests/test_process.py +31 -0
  27. gitlytics-0.1.6/src/gitlytics/static/assets/html2canvas-pro.esm-C9_j7xg5.js +0 -10
  28. gitlytics-0.1.6/src/gitlytics/static/assets/html2canvas.esm-QH1iLAAe.js +0 -22
  29. gitlytics-0.1.6/src/gitlytics/static/assets/index-D6vJCUrl.js +0 -504
  30. gitlytics-0.1.6/src/gitlytics/static/assets/index-hl2LPOqz.css +0 -1
  31. gitlytics-0.1.6/src/gitlytics/static/assets/jspdf.es.min-cihQsb1K.js +0 -170
  32. gitlytics-0.1.6/src/gitlytics/static/index.html +0 -19
  33. gitlytics-0.1.6/src/gitlytics/static/octocat.png +0 -0
  34. gitlytics-0.1.6/src/gitlytics/static/robots.txt +0 -5
  35. {gitlytics-0.1.6 → gitlytics-0.2.0}/LICENSE +0 -0
  36. {gitlytics-0.1.6 → gitlytics-0.2.0}/setup.cfg +0 -0
  37. {gitlytics-0.1.6 → gitlytics-0.2.0}/src/gitlytics/__main__.py +0 -0
  38. {gitlytics-0.1.6 → gitlytics-0.2.0}/src/gitlytics/cli.py +0 -0
  39. {gitlytics-0.1.6 → gitlytics-0.2.0}/src/gitlytics/static/android-chrome-192x192.png +0 -0
  40. {gitlytics-0.1.6 → gitlytics-0.2.0}/src/gitlytics/static/android-chrome-512x512.png +0 -0
  41. {gitlytics-0.1.6 → gitlytics-0.2.0}/src/gitlytics/static/apple-touch-icon.png +0 -0
  42. {gitlytics-0.1.6 → gitlytics-0.2.0}/src/gitlytics/static/favicon-16x16.png +0 -0
  43. {gitlytics-0.1.6 → gitlytics-0.2.0}/src/gitlytics/static/favicon-32x32.png +0 -0
  44. {gitlytics-0.1.6 → gitlytics-0.2.0}/src/gitlytics/static/favicon.ico +0 -0
  45. {gitlytics-0.1.6 → gitlytics-0.2.0}/src/gitlytics/static/gitlytics-logo.png +0 -0
  46. {gitlytics-0.1.6 → gitlytics-0.2.0}/src/gitlytics.egg-info/dependency_links.txt +0 -0
  47. {gitlytics-0.1.6 → gitlytics-0.2.0}/src/gitlytics.egg-info/entry_points.txt +0 -0
  48. {gitlytics-0.1.6 → gitlytics-0.2.0}/src/gitlytics.egg-info/requires.txt +0 -0
  49. {gitlytics-0.1.6 → gitlytics-0.2.0}/src/gitlytics.egg-info/top_level.txt +0 -0
  50. {gitlytics-0.1.6 → gitlytics-0.2.0}/tests/test_cli.py +0 -0
  51. {gitlytics-0.1.6 → gitlytics-0.2.0}/tests/test_core.py +0 -0
  52. {gitlytics-0.1.6 → gitlytics-0.2.0}/tests/test_username.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gitlytics
3
- Version: 0.1.6
3
+ Version: 0.2.0
4
4
  Summary: Monitor and automate your GitHub repository traffic analytics.
5
5
  Author-email: Ameya Chopade <ameyaccod171@gmail.com>
6
6
  License: Apache-2.0
@@ -96,7 +96,7 @@ Dynamic: license-file
96
96
 
97
97
  The full Gitlytics ecosystem spans across a few repositories. If you are looking for the live web dashboard or the automation cron job, check out the links below:
98
98
 
99
- - **[Gitlytics Web Ecosystem](https://github.com/ameyac11/gitlytics-deployement)**: The production landing page, React Dashboard, and React Documentation site.
99
+ - 📊 **[Gitlytics Live Dashboard](https://dashboard.gitlytics.dev)**: The production web interface to visualize repository traffic analytics, trends, and historical charts.
100
100
  - ⚙️ **[Gitlytics Automation](https://github.com/ameyac11/gitlytics-github-traffic-automation)**: The GitHub Action companion tool that automates fetching and saving to defeat GitHub's 14-day traffic limit.
101
101
 
102
102
  ---
@@ -64,7 +64,7 @@
64
64
 
65
65
  The full Gitlytics ecosystem spans across a few repositories. If you are looking for the live web dashboard or the automation cron job, check out the links below:
66
66
 
67
- - **[Gitlytics Web Ecosystem](https://github.com/ameyac11/gitlytics-deployement)**: The production landing page, React Dashboard, and React Documentation site.
67
+ - 📊 **[Gitlytics Live Dashboard](https://dashboard.gitlytics.dev)**: The production web interface to visualize repository traffic analytics, trends, and historical charts.
68
68
  - ⚙️ **[Gitlytics Automation](https://github.com/ameyac11/gitlytics-github-traffic-automation)**: The GitHub Action companion tool that automates fetching and saving to defeat GitHub's 14-day traffic limit.
69
69
 
70
70
  ---
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "gitlytics"
7
- version = "0.1.6"
7
+ version = "0.2.0"
8
8
  description = "Monitor and automate your GitHub repository traffic analytics."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -8,7 +8,7 @@ import json
8
8
 
9
9
  # Single source of truth for the package version.
10
10
  # Mirrors the version in pyproject.toml — keep them in sync.
11
- __version__ = "0.1.6"
11
+ __version__ = "0.2.0"
12
12
 
13
13
  __all__ = ["fetch_traffic", "sync", "serve_dashboard", "__version__"]
14
14
 
@@ -98,6 +98,11 @@ def sync(token: str, repo_name=None, data_dir: str = "./data", output_mode: str
98
98
  exported JSON — acts as a security firewall.
99
99
  metrics: Optional list of metrics to fetch (e.g., ``["views", "clones"]``).
100
100
  """
101
+ if data_dir and not os.path.isabs(data_dir) and not os.path.exists(data_dir):
102
+ parent_dir = os.path.join("..", data_dir)
103
+ if os.path.exists(parent_dir):
104
+ data_dir = parent_dir
105
+
101
106
  # Hand off to the automation engine — it handles deduplication and schema migration
102
107
  run_sync(
103
108
  token=token,
@@ -142,7 +147,17 @@ def serve_dashboard(host: str = "127.0.0.1", port: int = 8000, token: str = None
142
147
  if token:
143
148
  os.environ["GITLYTICS_TOKEN"] = token
144
149
  if data_dir:
145
- os.environ["GITLYTICS_DATA_DIR"] = os.path.abspath(data_dir)
150
+ from pathlib import Path
151
+ abs_data_dir = os.path.abspath(data_dir)
152
+ if not os.path.exists(abs_data_dir) and not os.path.isabs(data_dir):
153
+ parent_dir = os.path.abspath(os.path.join("..", data_dir))
154
+ if os.path.exists(parent_dir):
155
+ abs_data_dir = parent_dir
156
+ if not os.path.exists(abs_data_dir):
157
+ print(f"⚠️ Warning: The specified data directory '{data_dir}' (resolved to '{abs_data_dir}') does not exist.")
158
+ elif not any(Path(abs_data_dir).glob("traffic_*.csv")):
159
+ print(f"⚠️ Warning: No traffic_*.csv database files found in '{data_dir}' (resolved to '{abs_data_dir}').")
160
+ os.environ["GITLYTICS_DATA_DIR"] = abs_data_dir
146
161
  uvicorn.run("gitlytics.api:app", host=host, port=port, reload=False)
147
162
  finally:
148
163
  if _orig_token is None:
@@ -135,22 +135,38 @@ def get_traffic(token: str = Body("", embed=True)):
135
135
  if not ok:
136
136
  raise HTTPException(status_code=401, detail="Invalid token")
137
137
  data_dir = os.environ.get("GITLYTICS_DATA_DIR")
138
+ dfs = []
138
139
  if data_dir:
139
140
  data_dir_path = Path(data_dir)
140
- csv_files = list(data_dir_path.glob("traffic_*.csv")) if data_dir_path.exists() else []
141
- dfs = []
142
- for f in csv_files:
143
- try:
144
- dfs.append(pd.read_csv(f))
145
- except Exception as exc:
146
- logger.warning(f"Skipping unreadable CSV '{f}': {exc}")
147
- if dfs:
148
- df = pd.concat(dfs, ignore_index=True)
149
- df = df.drop_duplicates(subset=["date", "repository"], keep="last")
141
+ if not data_dir_path.exists():
142
+ logger.warning(f"Data directory '{data_dir}' does not exist.")
150
143
  else:
151
- df = fetch_traffic_data(active_token)
144
+ csv_files = list(data_dir_path.glob("traffic_*.csv"))
145
+ if not csv_files:
146
+ logger.warning(f"No traffic_*.csv files found in '{data_dir}'.")
147
+ for f in csv_files:
148
+ try:
149
+ dfs.append(pd.read_csv(f))
150
+ except Exception as exc:
151
+ logger.warning(f"Skipping unreadable CSV '{f}': {exc}")
152
+
153
+ try:
154
+ live_df = fetch_traffic_data(active_token)
155
+ except Exception as exc:
156
+ logger.warning(f"Failed to fetch live traffic: {exc}")
157
+ live_df = pd.DataFrame()
158
+
159
+ if dfs:
160
+ csv_df = pd.concat(dfs, ignore_index=True)
161
+ if not live_df.empty:
162
+ df = pd.concat([csv_df, live_df], ignore_index=True)
163
+ else:
164
+ df = csv_df
152
165
  else:
153
- df = fetch_traffic_data(active_token)
166
+ df = live_df
167
+
168
+ if not df.empty:
169
+ df = df.drop_duplicates(subset=["date", "repository"], keep="last")
154
170
 
155
171
  df = df.replace([float('inf'), float('-inf')], None).where(pd.notnull(df), None)
156
172
 
@@ -171,8 +187,17 @@ def get_traffic(token: str = Body("", embed=True)):
171
187
 
172
188
  @app.post("/api/upload-csv")
173
189
  def upload_csv(file: UploadFile = File(...)):
174
- # Accept a user-uploaded CSV — deep stats not available in CSV mode
175
190
  try:
191
+ data_dir = os.environ.get("GITLYTICS_DATA_DIR")
192
+ if data_dir:
193
+ data_dir_path = Path(data_dir)
194
+ data_dir_path.mkdir(parents=True, exist_ok=True)
195
+ file.file.seek(0)
196
+ content = file.file.read()
197
+ file.file.seek(0)
198
+ dest = data_dir_path / f"traffic_uploaded_{int(_time.time())}.csv"
199
+ with open(dest, "wb") as f:
200
+ f.write(content)
176
201
  df = process_uploaded_csv(file.file)
177
202
  df = df.replace([float('inf'), float('-inf')], None).where(pd.notnull(df), None)
178
203
  payload = build_react_payload(df, deep_stats=None)
@@ -192,7 +217,7 @@ def serve_index():
192
217
  return FileResponse(index_file)
193
218
  return JSONResponse(
194
219
  status_code=503,
195
- content={"error": "Dashboard not found. Run 'npm run build' in the dashboard directory."}
220
+ content={"error": "Dashboard assets not found in the package installation."}
196
221
  )
197
222
 
198
223
 
@@ -209,7 +234,7 @@ def serve_spa_fallback(full_path: str):
209
234
 
210
235
  return JSONResponse(
211
236
  status_code=503,
212
- content={"error": "Dashboard not found. Run 'npm run build' in the dashboard directory."}
237
+ content={"error": "Dashboard assets not found in the package installation."}
213
238
  )
214
239
 
215
240
 
@@ -130,13 +130,15 @@ def run_sync_cycle(token: str, repo_names=None, data_dir="./data", output_mode="
130
130
  else:
131
131
  existing_fields = new_fields
132
132
 
133
- # Overwrite existing rows with fresh data, and add brand-new day rows
133
+ # Merge fresh data into existing rows preserves columns not present in this sync run
134
134
  new_records_added = 0
135
135
  for _, row in df.iterrows():
136
136
  key = (str(row["repository"]), str(row["date"]))
137
137
  if key not in existing_data:
138
138
  new_records_added += 1
139
- existing_data[key] = row.to_dict()
139
+ existing_data[key] = row.to_dict()
140
+ else:
141
+ existing_data[key].update(row.to_dict())
140
142
 
141
143
  # Sort all rows by date and repo name before writing back to disk
142
144
  final_rows = []
@@ -221,8 +221,8 @@ def get_deep_repo_stats(token: str, full_name: str) -> dict:
221
221
  "has_code_of_conduct": None,
222
222
  }
223
223
 
224
- # Commit activity — GitHub computes this async and returns 202 when not ready
225
- # We do not block the thread pool worker with sleep; accept None on 202 (C-1)
224
+ # Commit activity — GitHub computes this async and returns 202 when not ready.
225
+ # total_commits here is the trailing 52-week (12-month) sum, NOT lifetime.
226
226
  ca_url = f"{BASE}/repos/{full_name}/stats/commit_activity"
227
227
  ca_data, status = _safe_get(ca_url, h)
228
228
  if status == 202:
@@ -254,12 +254,35 @@ def get_deep_repo_stats(token: str, full_name: str) -> dict:
254
254
  stats["has_contributing"] = bool(files.get("contributing"))
255
255
  stats["has_code_of_conduct"] = bool(files.get("code_of_conduct"))
256
256
 
257
- # Releases — count and most recent publish date
258
- rel_data, _ = _safe_get(f"{BASE}/repos/{full_name}/releases", h, params={"per_page": 100})
259
- if isinstance(rel_data, list):
260
- stats["total_releases"] = len(rel_data)
261
- if rel_data:
262
- stats["last_release_at"] = rel_data[0].get("published_at") or rel_data[0].get("created_at")
257
+ # Releases — count real total using Link header pagination, then get latest date
258
+ try:
259
+ rel_resp = requests.get(
260
+ f"{BASE}/repos/{full_name}/releases",
261
+ headers=h,
262
+ params={"per_page": 1},
263
+ timeout=10,
264
+ )
265
+ if rel_resp.status_code == 200:
266
+ link = rel_resp.headers.get("Link", "")
267
+ if 'rel="last"' in link:
268
+ import re as _re
269
+ m = _re.search(r'page=(\d+)>; rel="last"', link)
270
+ stats["total_releases"] = int(m.group(1)) if m else 1
271
+ else:
272
+ # Only one page — count items in this page
273
+ items = rel_resp.json()
274
+ stats["total_releases"] = len(items) if isinstance(items, list) else 0
275
+ # Fetch the latest release separately for its date
276
+ latest_resp = requests.get(
277
+ f"{BASE}/repos/{full_name}/releases/latest",
278
+ headers=h,
279
+ timeout=10,
280
+ )
281
+ if latest_resp.status_code == 200:
282
+ latest = latest_resp.json()
283
+ stats["last_release_at"] = latest.get("published_at") or latest.get("created_at")
284
+ except Exception as exc:
285
+ logger.warning(f"Could not fetch releases for {full_name}: {exc}")
263
286
 
264
287
  return stats
265
288
 
@@ -35,12 +35,12 @@ def build_json_payload(df: pd.DataFrame, return_format: str = "timeseries", expo
35
35
  for repo, group in df.groupby("repository"):
36
36
  group = group.sort_values("date")
37
37
 
38
- r_views = int(group["views"].sum()) if "views" in group.columns else 0
39
- r_clones = int(group["clones"].sum()) if "clones" in group.columns else 0
40
- r_unique_v = int(group["unique_visitors"].sum()) if "unique_visitors" in group.columns else 0
41
- r_unique_c = int(group["unique_cloners"].sum()) if "unique_cloners" in group.columns else 0
42
- r_stars = int(group["stars"].dropna().iloc[-1]) if "stars" in group.columns and not group["stars"].dropna().empty else 0
43
- r_forks = int(group["forks"].dropna().iloc[-1]) if "forks" in group.columns and not group["forks"].dropna().empty else 0
38
+ r_views = _safe_int(group["views"].sum()) if "views" in group.columns else 0
39
+ r_clones = _safe_int(group["clones"].sum()) if "clones" in group.columns else 0
40
+ r_unique_v = _safe_int(group["unique_visitors"].sum()) if "unique_visitors" in group.columns else 0
41
+ r_unique_c = _safe_int(group["unique_cloners"].sum()) if "unique_cloners" in group.columns else 0
42
+ r_stars = _safe_int(group["stars"].dropna().iloc[-1]) if "stars" in group.columns and not group["stars"].dropna().empty else 0
43
+ r_forks = _safe_int(group["forks"].dropna().iloc[-1]) if "forks" in group.columns and not group["forks"].dropna().empty else 0
44
44
  r_is_private = bool(group["is_private"].dropna().iloc[-1]) if "is_private" in group.columns and not group["is_private"].dropna().empty else False
45
45
 
46
46
  top_ref = str(group["top_referrer"].dropna().iloc[-1]) if "top_referrer" in group.columns and not group["top_referrer"].dropna().empty else ""
@@ -103,6 +103,11 @@ def build_json_payload(df: pd.DataFrame, return_format: str = "timeseries", expo
103
103
  def process_uploaded_csv(uploaded_file) -> pd.DataFrame:
104
104
  """Reads a user-uploaded CSV and normalises column names to match our tidy schema."""
105
105
  raw_df = pd.read_csv(uploaded_file)
106
+
107
+ # Normalise capitalized "Date" column before any other checks
108
+ if "Date" in raw_df.columns and "date" not in raw_df.columns:
109
+ raw_df = raw_df.rename(columns={"Date": "date"})
110
+
106
111
  if "repository" not in raw_df.columns:
107
112
  if "repo_name" in raw_df.columns:
108
113
  raw_df = raw_df.rename(columns={"repo_name": "repository"})
@@ -113,7 +118,7 @@ def process_uploaded_csv(uploaded_file) -> pd.DataFrame:
113
118
  })
114
119
  else:
115
120
  raise ValueError("Invalid CSV format: missing 'repository' column")
116
- # M-5: validate date column exists after renaming so callers get 400 not a 500 KeyError later
121
+
117
122
  if "date" not in raw_df.columns:
118
123
  raise ValueError("Invalid CSV format: missing required 'date' column")
119
124
  return raw_df
@@ -161,10 +166,10 @@ def build_react_payload(df: pd.DataFrame, deep_stats: dict = None) -> list:
161
166
  for repo, group in df.groupby("repository"):
162
167
  group = group.sort_values("date")
163
168
 
164
- r_views = int(group["views"].sum()) if "views" in group.columns else 0
165
- r_clones = int(group["clones"].sum()) if "clones" in group.columns else 0
166
- r_unique_v = int(group["unique_visitors"].sum()) if "unique_visitors" in group.columns else 0
167
- r_unique_c = int(group["unique_cloners"].sum()) if "unique_cloners" in group.columns else 0
169
+ r_views = _safe_int(group["views"].sum()) if "views" in group.columns else 0
170
+ r_clones = _safe_int(group["clones"].sum()) if "clones" in group.columns else 0
171
+ r_unique_v = _safe_int(group["unique_visitors"].sum()) if "unique_visitors" in group.columns else 0
172
+ r_unique_c = _safe_int(group["unique_cloners"].sum()) if "unique_cloners" in group.columns else 0
168
173
  r_stars = _safe_int(group["stars"].dropna().iloc[-1]) if "stars" in group.columns and not group["stars"].dropna().empty else 0
169
174
  r_forks = _safe_int(group["forks"].dropna().iloc[-1]) if "forks" in group.columns and not group["forks"].dropna().empty else 0
170
175
  r_is_private = bool(group["is_private"].dropna().iloc[-1]) if "is_private" in group.columns and not group["is_private"].dropna().empty else False
@@ -194,13 +199,13 @@ def build_react_payload(df: pd.DataFrame, deep_stats: dict = None) -> list:
194
199
  date_str = str(row["date"])
195
200
  daily_views.append({
196
201
  "timestamp": date_str,
197
- "count": int(row.get("views", 0)),
198
- "uniques": int(row.get("unique_visitors", 0))
202
+ "count": _safe_int(row.get("views", 0)),
203
+ "uniques": _safe_int(row.get("unique_visitors", 0))
199
204
  })
200
205
  daily_clones.append({
201
206
  "timestamp": date_str,
202
- "count": int(row.get("clones", 0)),
203
- "uniques": int(row.get("unique_cloners", 0)) # fixed typo
207
+ "count": _safe_int(row.get("clones", 0)),
208
+ "uniques": _safe_int(row.get("unique_cloners", 0)) # fixed typo
204
209
  })
205
210
 
206
211
  raw_refs_val = group["_raw_referrers"].iloc[-1] if "_raw_referrers" in group.columns else None