umpyre 0.1.7__tar.gz → 0.1.9__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.

Potentially problematic release.


This version of umpyre might be problematic. Click here for more details.

Files changed (44) hide show
  1. {umpyre-0.1.7 → umpyre-0.1.9}/PKG-INFO +6 -6
  2. {umpyre-0.1.7 → umpyre-0.1.9}/README.md +4 -4
  3. umpyre-0.1.9/actions/track-metrics/action.yml +90 -0
  4. {umpyre-0.1.7 → umpyre-0.1.9}/misc/docs/FAILURE_PROTECTION.md +4 -7
  5. {umpyre-0.1.7 → umpyre-0.1.9}/misc/docs/PHASE_2_PLAN.md +21 -22
  6. {umpyre-0.1.7 → umpyre-0.1.9}/misc/docs/PHASE_3_PLAN.md +54 -47
  7. {umpyre-0.1.7 → umpyre-0.1.9}/misc/docs/STORAGE_STRUCTURE.md +14 -8
  8. {umpyre-0.1.7 → umpyre-0.1.9}/pyproject.toml +1 -1
  9. {umpyre-0.1.7 → umpyre-0.1.9}/setup.cfg +1 -1
  10. umpyre-0.1.9/tests/test_git_branch_storage.py +77 -0
  11. {umpyre-0.1.7 → umpyre-0.1.9}/umpyre/cli.py +9 -3
  12. {umpyre-0.1.7 → umpyre-0.1.9}/umpyre/python_code_stats.py +0 -9
  13. {umpyre-0.1.7 → umpyre-0.1.9}/umpyre/storage/git_branch.py +68 -4
  14. umpyre-0.1.7/actions/track-metrics/action.yml +0 -74
  15. {umpyre-0.1.7 → umpyre-0.1.9}/.gitattributes +0 -0
  16. {umpyre-0.1.7 → umpyre-0.1.9}/.github/umpyre-config.yml +0 -0
  17. {umpyre-0.1.7 → umpyre-0.1.9}/.github/workflows/ci.yml +0 -0
  18. {umpyre-0.1.7 → umpyre-0.1.9}/.gitignore +0 -0
  19. {umpyre-0.1.7 → umpyre-0.1.9}/LICENSE +0 -0
  20. {umpyre-0.1.7 → umpyre-0.1.9}/QUICK_START.md +0 -0
  21. {umpyre-0.1.7 → umpyre-0.1.9}/json +0 -0
  22. {umpyre-0.1.7 → umpyre-0.1.9}/misc/docs/CHANGELOG.md +0 -0
  23. {umpyre-0.1.7 → umpyre-0.1.9}/misc/docs/IMPLEMENTATION_STATUS.md +0 -0
  24. {umpyre-0.1.7 → umpyre-0.1.9}/misc/docs/IMPLEMENTATION_SUMMARY.md +0 -0
  25. {umpyre-0.1.7 → umpyre-0.1.9}/misc/docs/TESTING_GUIDE.md +0 -0
  26. {umpyre-0.1.7 → umpyre-0.1.9}/misc/docs/TEST_RESULTS.md +0 -0
  27. {umpyre-0.1.7 → umpyre-0.1.9}/test_on_astate.py +0 -0
  28. {umpyre-0.1.7 → umpyre-0.1.9}/tests/test_base_collector.py +0 -0
  29. {umpyre-0.1.7 → umpyre-0.1.9}/tests/test_config.py +0 -0
  30. {umpyre-0.1.7 → umpyre-0.1.9}/tests/test_coverage_collector.py +0 -0
  31. {umpyre-0.1.7 → umpyre-0.1.9}/tests/test_schema.py +0 -0
  32. {umpyre-0.1.7 → umpyre-0.1.9}/tests/test_umpyre_collector.py +0 -0
  33. {umpyre-0.1.7 → umpyre-0.1.9}/umpyre/__init__.py +0 -0
  34. {umpyre-0.1.7 → umpyre-0.1.9}/umpyre/collectors/__init__.py +0 -0
  35. {umpyre-0.1.7 → umpyre-0.1.9}/umpyre/collectors/base.py +0 -0
  36. {umpyre-0.1.7 → umpyre-0.1.9}/umpyre/collectors/coverage_collector.py +0 -0
  37. {umpyre-0.1.7 → umpyre-0.1.9}/umpyre/collectors/umpyre_collector.py +0 -0
  38. {umpyre-0.1.7 → umpyre-0.1.9}/umpyre/collectors/wily_collector.py +0 -0
  39. {umpyre-0.1.7 → umpyre-0.1.9}/umpyre/collectors/workflow_status.py +0 -0
  40. {umpyre-0.1.7 → umpyre-0.1.9}/umpyre/config.py +0 -0
  41. {umpyre-0.1.7 → umpyre-0.1.9}/umpyre/schema.py +0 -0
  42. {umpyre-0.1.7 → umpyre-0.1.9}/umpyre/storage/__init__.py +0 -0
  43. {umpyre-0.1.7 → umpyre-0.1.9}/umpyre/storage/formats.py +0 -0
  44. {umpyre-0.1.7 → umpyre-0.1.9}/umpyre/storage/query_utils.py +0 -0
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: umpyre
3
- Version: 0.1.7
3
+ Version: 0.1.9
4
4
  Summary: Code analysis and quality metrics tracking system for CI/CD
5
5
  Project-URL: Homepage, https://github.com/i2mint/umpyre
6
6
  License: Apache-2.0
@@ -144,9 +144,9 @@ history_dir = Path("history")
144
144
 
145
145
  # Parse filename
146
146
  info = parse_metric_filename("2025_11_14_22_45_00__700e012__0.1.0.json")
147
- print(info['commit_sha']) # '700e012'
148
- print(info['pypi_version']) # '0.1.0'
149
- print(info['timestamp']) # datetime object
147
+ print(info["commit_sha"]) # '700e012'
148
+ print(info["pypi_version"]) # '0.1.0'
149
+ print(info["timestamp"]) # datetime object
150
150
 
151
151
  # Find by commit
152
152
  metrics = find_metrics_by_commit(history_dir, "700e012")
@@ -318,7 +318,7 @@ import collections
318
318
  modules_info_df(collections)
319
319
 
320
320
  # Compare multiple packages
321
- stats_of(['urllib', 'json', 'collections'])
321
+ stats_of(["urllib", "json", "collections"])
322
322
  ```
323
323
 
324
324
  See original README examples above for detailed usage.
@@ -129,9 +129,9 @@ history_dir = Path("history")
129
129
 
130
130
  # Parse filename
131
131
  info = parse_metric_filename("2025_11_14_22_45_00__700e012__0.1.0.json")
132
- print(info['commit_sha']) # '700e012'
133
- print(info['pypi_version']) # '0.1.0'
134
- print(info['timestamp']) # datetime object
132
+ print(info["commit_sha"]) # '700e012'
133
+ print(info["pypi_version"]) # '0.1.0'
134
+ print(info["timestamp"]) # datetime object
135
135
 
136
136
  # Find by commit
137
137
  metrics = find_metrics_by_commit(history_dir, "700e012")
@@ -303,7 +303,7 @@ import collections
303
303
  modules_info_df(collections)
304
304
 
305
305
  # Compare multiple packages
306
- stats_of(['urllib', 'json', 'collections'])
306
+ stats_of(["urllib", "json", "collections"])
307
307
  ```
308
308
 
309
309
  See original README examples above for detailed usage.
@@ -0,0 +1,90 @@
1
+ name: Track Code Metrics
2
+ description: Collect and store code quality metrics for CI/CD pipelines
3
+
4
+ inputs:
5
+ config-path:
6
+ description: 'Path to umpyre config file'
7
+ default: '.github/umpyre-config.yml'
8
+ required: false
9
+
10
+ storage-branch:
11
+ description: 'Branch to store metrics on (the default leaves the choice to storage.branch in the config file)'
12
+ default: 'code-metrics'
13
+ required: false
14
+
15
+ github-token:
16
+ description: 'GitHub token: read by the workflow_status collector and used to push the metrics branch (needs contents: write; with a read-only token the push fails, non-fatally)'
17
+ required: true
18
+
19
+ force-run:
20
+ description: "Run even if an earlier step of the action failed ('true' or 'false'). To run after a failed step of the calling job, also give the calling step `if: always()`"
21
+ default: 'false'
22
+ required: false
23
+
24
+ python-version:
25
+ description: 'Python version to use'
26
+ default: '3.10'
27
+ required: false
28
+
29
+ runs:
30
+ using: composite
31
+ steps:
32
+ - name: Setup Python
33
+ uses: actions/setup-python@v5
34
+ with:
35
+ python-version: ${{ inputs.python-version }}
36
+
37
+ - name: Install umpyre and dependencies
38
+ shell: bash
39
+ run: |
40
+ pip install umpyre coverage wily bandit interrogate
41
+
42
+ - name: Collect and Store Metrics
43
+ # Run on success, or on failure too when `force-run` is 'true'.
44
+ if: ${{ success() || (inputs.force-run == 'true' && !cancelled()) }}
45
+ shell: bash
46
+ env:
47
+ # Inputs reach the script as environment variables, never pasted into it.
48
+ CONFIG_PATH: ${{ inputs.config-path }}
49
+ STORAGE_BRANCH: ${{ inputs.storage-branch }}
50
+ # Used by the workflow_status collector and to push the metrics branch.
51
+ GITHUB_TOKEN: ${{ inputs.github-token }}
52
+ run: |
53
+ echo "::group::Collecting Code Metrics"
54
+
55
+ # Set up error handling - continue even if collection fails
56
+ set +e
57
+
58
+ # Build the command. Run from $RUNNER_TEMP in isolated mode (-I) so that a
59
+ # file in the checked-out repo (umpyre/, yaml.py, ...) can't shadow an
60
+ # installed package; the repository is passed explicitly.
61
+ cmd=(python -I -m umpyre.cli collect --repo-path "$GITHUB_WORKSPACE")
62
+ if [ -n "$CONFIG_PATH" ] && [ -f "$GITHUB_WORKSPACE/$CONFIG_PATH" ]; then
63
+ cmd+=(--config "$GITHUB_WORKSPACE/$CONFIG_PATH")
64
+ elif [ -n "$CONFIG_PATH" ] && [ -f "$CONFIG_PATH" ]; then
65
+ cmd+=(--config "$CONFIG_PATH")
66
+ fi
67
+ # The input's default leaves the choice to the config file (storage.branch).
68
+ if [ -n "$STORAGE_BRANCH" ] && [ "$STORAGE_BRANCH" != "code-metrics" ]; then
69
+ cmd+=(--storage-branch "$STORAGE_BRANCH")
70
+ fi
71
+
72
+ (cd "${RUNNER_TEMP:-/tmp}" && "${cmd[@]}")
73
+ EXIT_CODE=$?
74
+
75
+ # Report status but don't fail
76
+ if [ $EXIT_CODE -eq 0 ]; then
77
+ echo "✅ Metrics collection completed successfully"
78
+ else
79
+ echo "⚠️ Metrics collection failed with exit code $EXIT_CODE"
80
+ echo "This is informational only - CI pipeline continues"
81
+ fi
82
+
83
+ echo "::endgroup::"
84
+
85
+ # Always exit successfully to not break CI
86
+ exit 0
87
+
88
+ branding:
89
+ icon: 'bar-chart-2'
90
+ color: 'blue'
@@ -59,12 +59,12 @@ def cmd_collect(args):
59
59
  return 0
60
60
  except Exception as e:
61
61
  print(f"❌ Error during metrics collection: {e}")
62
-
62
+
63
63
  # In CI mode, log but don't fail
64
- if os.getenv('CI') or os.getenv('GITHUB_ACTIONS'):
64
+ if os.getenv("CI") or os.getenv("GITHUB_ACTIONS"):
65
65
  print("⚠️ Running in CI - treating as non-fatal")
66
66
  return 0
67
-
67
+
68
68
  return 1 # Fail in local development
69
69
  ```
70
70
 
@@ -136,10 +136,7 @@ def collect(self) -> dict:
136
136
  # ... collect metrics ...
137
137
  return metrics
138
138
  except Exception as e:
139
- return {
140
- "num_functions": 0,
141
- "error": str(e)
142
- }
139
+ return {"num_functions": 0, "error": str(e)}
143
140
  ```
144
141
 
145
142
  This ensures one broken collector doesn't fail the entire collection.
@@ -27,12 +27,13 @@ Generate visual charts showing metric trends over time.
27
27
  ```python
28
28
  # umpyre/visualization/plots.py
29
29
 
30
+
30
31
  class PlotGenerator:
31
32
  """Generate time-series plots from metrics history."""
32
-
33
+
33
34
  def __init__(self, metrics_branch: str = "code-metrics"):
34
35
  self.branch = metrics_branch
35
-
36
+
36
37
  def generate_coverage_plot(self) -> Path:
37
38
  """Generate coverage trend plot."""
38
39
  # Parse history/*.json files
@@ -40,11 +41,11 @@ class PlotGenerator:
40
41
  # Plot with matplotlib/plotly
41
42
  # Save as PNG/SVG
42
43
  pass
43
-
44
+
44
45
  def generate_maintainability_plot(self) -> Path:
45
46
  """Generate maintainability trend plot."""
46
47
  pass
47
-
48
+
48
49
  def generate_all_plots(self) -> dict[str, Path]:
49
50
  """Generate all standard plots."""
50
51
  pass
@@ -132,21 +133,22 @@ Version: 0.1.0
132
133
  ```python
133
134
  # umpyre/visualization/readme_generator.py
134
135
 
136
+
135
137
  class ReadmeGenerator:
136
138
  """Generate METRICS.md from metrics history."""
137
-
139
+
138
140
  def generate_summary_table(self) -> str:
139
141
  """Create latest metrics table."""
140
142
  pass
141
-
143
+
142
144
  def generate_trend_section(self) -> str:
143
145
  """Embed plot images."""
144
146
  pass
145
-
147
+
146
148
  def generate_badges(self) -> str:
147
149
  """Create shields.io badges."""
148
150
  pass
149
-
151
+
150
152
  def generate_full_readme(self) -> str:
151
153
  """Generate complete METRICS.md."""
152
154
  pass
@@ -179,18 +181,14 @@ Aggregate metrics across multiple repositories to provide organization-wide insi
179
181
  ```python
180
182
  # umpyre/collectors/aggregation_collector.py
181
183
 
184
+
182
185
  class AggregationCollector(MetricCollector):
183
186
  """Aggregate metrics across multiple repositories."""
184
-
185
- def __init__(
186
- self,
187
- org: str,
188
- repos: list[str],
189
- github_token: Optional[str] = None
190
- ):
187
+
188
+ def __init__(self, org: str, repos: list[str], github_token: Optional[str] = None):
191
189
  """
192
190
  Initialize aggregator.
193
-
191
+
194
192
  Args:
195
193
  org: GitHub organization name
196
194
  repos: List of repository names
@@ -199,11 +197,11 @@ class AggregationCollector(MetricCollector):
199
197
  self.org = org
200
198
  self.repos = repos
201
199
  self.token = github_token
202
-
200
+
203
201
  def collect(self) -> dict:
204
202
  """
205
203
  Aggregate metrics from multiple repos.
206
-
204
+
207
205
  Returns:
208
206
  Aggregated metrics dictionary
209
207
  """
@@ -211,7 +209,7 @@ class AggregationCollector(MetricCollector):
211
209
  # 1. Clone code-metrics branch
212
210
  # 2. Read latest metrics.json
213
211
  # 3. Aggregate
214
-
212
+
215
213
  return {
216
214
  "total_repos": len(self.repos),
217
215
  "avg_coverage": 82.5,
@@ -267,16 +265,17 @@ Create interactive HTML dashboard with organization-wide metrics.
267
265
  ```python
268
266
  # umpyre/visualization/dashboard.py
269
267
 
268
+
270
269
  class DashboardGenerator:
271
270
  """Generate interactive HTML dashboard."""
272
-
271
+
273
272
  def generate_html(self, aggregated_metrics: dict) -> str:
274
273
  """
275
274
  Generate dashboard HTML.
276
-
275
+
277
276
  Args:
278
277
  aggregated_metrics: Output from AggregationCollector
279
-
278
+
280
279
  Returns:
281
280
  HTML string
282
281
  """
@@ -32,30 +32,33 @@ Enforce quality standards by validating metrics against configurable thresholds.
32
32
  ```python
33
33
  # umpyre/validation/thresholds.py
34
34
 
35
+
35
36
  @dataclass
36
37
  class Threshold:
37
38
  """Threshold definition."""
39
+
38
40
  metric_path: str # e.g., "metrics.coverage.line_coverage"
39
- operator: str # ">=", "<=", "==", "!=", ">", "<"
41
+ operator: str # ">=", "<=", "==", "!=", ">", "<"
40
42
  value: float
41
- action: str # "warn", "fail", "notify", "comment"
43
+ action: str # "warn", "fail", "notify", "comment"
42
44
  message: Optional[str] = None
43
45
 
46
+
44
47
  class ThresholdValidator:
45
48
  """Validate metrics against thresholds."""
46
-
49
+
47
50
  def __init__(self, thresholds: list[Threshold]):
48
51
  self.thresholds = thresholds
49
-
52
+
50
53
  def validate(self, metrics: dict) -> list[ThresholdViolation]:
51
54
  """
52
55
  Validate metrics against all thresholds.
53
-
56
+
54
57
  Returns:
55
58
  List of violations
56
59
  """
57
60
  violations = []
58
-
61
+
59
62
  for threshold in self.thresholds:
60
63
  value = self._extract_metric(metrics, threshold.metric_path)
61
64
  if not self._check_threshold(value, threshold):
@@ -66,9 +69,9 @@ class ThresholdValidator:
66
69
  expected_value=threshold.value,
67
70
  )
68
71
  )
69
-
72
+
70
73
  return violations
71
-
74
+
72
75
  def _check_threshold(self, value: float, threshold: Threshold) -> bool:
73
76
  """Check if value passes threshold."""
74
77
  ops = {
@@ -119,17 +122,18 @@ thresholds:
119
122
  ```python
120
123
  # umpyre/validators.py
121
124
 
125
+
122
126
  def check_docstring_ratio(metrics: dict) -> tuple[bool, str]:
123
127
  """
124
128
  Custom validator: check if docstring ratio is acceptable.
125
-
129
+
126
130
  Returns:
127
131
  (passed, message)
128
132
  """
129
133
  docs = metrics["metrics"]["umpyre_stats"]["docs_lines"]
130
134
  total = metrics["metrics"]["umpyre_stats"]["total_lines"]
131
135
  ratio = docs / total if total > 0 else 0
132
-
136
+
133
137
  if ratio < 0.1:
134
138
  return False, f"Docstring ratio {ratio:.1%} is below 10%"
135
139
  return True, "Docstring ratio acceptable"
@@ -158,13 +162,14 @@ python -m umpyre.cli validate --violations-only
158
162
  ```python
159
163
  # umpyre/collectors/bandit_collector.py
160
164
 
165
+
161
166
  class BanditCollector(MetricCollector):
162
167
  """Collect security metrics using Bandit."""
163
-
168
+
164
169
  def collect(self) -> dict:
165
170
  """
166
171
  Run bandit security scan.
167
-
172
+
168
173
  Returns:
169
174
  {
170
175
  "total_issues": 12,
@@ -206,13 +211,14 @@ collectors:
206
211
  ```python
207
212
  # umpyre/collectors/interrogate_collector.py
208
213
 
214
+
209
215
  class InterrogateCollector(MetricCollector):
210
216
  """Collect docstring coverage using interrogate."""
211
-
217
+
212
218
  def collect(self) -> dict:
213
219
  """
214
220
  Run interrogate to check docstring coverage.
215
-
221
+
216
222
  Returns:
217
223
  {
218
224
  "coverage": 78.5,
@@ -237,13 +243,14 @@ class InterrogateCollector(MetricCollector):
237
243
  ```python
238
244
  # umpyre/collectors/mypy_collector.py
239
245
 
246
+
240
247
  class MyPyCollector(MetricCollector):
241
248
  """Collect type checking results using mypy."""
242
-
249
+
243
250
  def collect(self) -> dict:
244
251
  """
245
252
  Run mypy type checker.
246
-
253
+
247
254
  Returns:
248
255
  {
249
256
  "total_errors": 15,
@@ -273,13 +280,14 @@ class MyPyCollector(MetricCollector):
273
280
  ```python
274
281
  # umpyre/collectors/pylint_collector.py
275
282
 
283
+
276
284
  class PylintCollector(MetricCollector):
277
285
  """Collect code quality score using Pylint."""
278
-
286
+
279
287
  def collect(self) -> dict:
280
288
  """
281
289
  Run pylint code analysis.
282
-
290
+
283
291
  Returns:
284
292
  {
285
293
  "score": 8.5, # Out of 10
@@ -314,13 +322,14 @@ Manage metrics history efficiently (pruning, compression, rotation).
314
322
  ```python
315
323
  # umpyre/storage/pruning.py
316
324
 
325
+
317
326
  class MetricsPruner:
318
327
  """Prune old metrics to save space."""
319
-
328
+
320
329
  def prune(self, branch: str = "code-metrics"):
321
330
  """
322
331
  Prune metrics history based on retention policy.
323
-
332
+
324
333
  Strategy:
325
334
  - Keep all metrics < 30 days old
326
335
  - Keep weekly snapshots for 30-90 days
@@ -353,17 +362,16 @@ storage:
353
362
  ```python
354
363
  # umpyre/storage/compression.py
355
364
 
365
+
356
366
  class MetricsCompressor:
357
367
  """Compress old metrics files."""
358
-
368
+
359
369
  def compress_old_files(
360
- self,
361
- branch: str = "code-metrics",
362
- older_than_days: int = 90
370
+ self, branch: str = "code-metrics", older_than_days: int = 90
363
371
  ):
364
372
  """
365
373
  Compress metrics older than N days.
366
-
374
+
367
375
  Changes:
368
376
  - 2024_01_15_10_30_00__abc1234__0.1.0.json
369
377
  → 2024_01_15_10_30_00__abc1234__0.1.0.json.gz
@@ -383,14 +391,11 @@ class MetricsCompressor:
383
391
  ```python
384
392
  # umpyre/storage/rotation.py
385
393
 
394
+
386
395
  class MetricsRotator:
387
396
  """Rotate out very old metrics."""
388
-
389
- def rotate(
390
- self,
391
- branch: str = "code-metrics",
392
- max_age_days: int = 365
393
- ):
397
+
398
+ def rotate(self, branch: str = "code-metrics", max_age_days: int = 365):
394
399
  """Delete metrics older than max_age_days."""
395
400
  # Parse timestamps
396
401
  # Delete files older than threshold
@@ -418,47 +423,48 @@ Handle schema version changes gracefully with automatic migrations.
418
423
  ```python
419
424
  # umpyre/schema.py (enhanced)
420
425
 
426
+
421
427
  class MetricSchema:
422
428
  """Versioned schema with migration support."""
423
-
429
+
424
430
  version: str = "1.1" # Bump version
425
-
431
+
426
432
  @classmethod
427
433
  def migrate(cls, data: dict, from_version: str) -> dict:
428
434
  """
429
435
  Migrate data from old schema to current.
430
-
436
+
431
437
  Migration chain:
432
438
  1.0 → 1.1 → 1.2 → ...
433
439
  """
434
440
  if from_version == cls.current_version():
435
441
  return data
436
-
442
+
437
443
  # Migration registry
438
444
  migrations = {
439
445
  "1.0": cls._migrate_1_0_to_1_1,
440
446
  "1.1": cls._migrate_1_1_to_1_2,
441
447
  }
442
-
448
+
443
449
  # Apply migrations in sequence
444
450
  current_version = from_version
445
451
  current_data = data
446
-
452
+
447
453
  while current_version != cls.current_version():
448
454
  if current_version not in migrations:
449
455
  raise ValueError(f"No migration path from {current_version}")
450
-
456
+
451
457
  migrator = migrations[current_version]
452
458
  current_data = migrator(current_data)
453
459
  current_version = cls._next_version(current_version)
454
-
460
+
455
461
  return current_data
456
-
462
+
457
463
  @classmethod
458
464
  def _migrate_1_0_to_1_1(cls, data: dict) -> dict:
459
465
  """
460
466
  Migrate from schema 1.0 to 1.1.
461
-
467
+
462
468
  Changes in 1.1:
463
469
  - Added pypi_version field
464
470
  - Added collection_duration_seconds
@@ -468,10 +474,10 @@ class MetricSchema:
468
474
  for collector_name, metrics in data["metrics"].items():
469
475
  if "pypi_version" not in metrics:
470
476
  metrics["pypi_version"] = None
471
-
477
+
472
478
  data["schema_version"] = "1.1"
473
479
  return data
474
-
480
+
475
481
  @classmethod
476
482
  def _migrate_1_1_to_1_2(cls, data: dict) -> dict:
477
483
  """Future migration example."""
@@ -484,22 +490,23 @@ class MetricSchema:
484
490
  ```python
485
491
  # umpyre/storage/formats.py (enhanced)
486
492
 
493
+
487
494
  def load_metrics(filepath: Path) -> dict:
488
495
  """
489
496
  Load metrics with automatic schema migration.
490
-
497
+
491
498
  If old schema detected, automatically migrates to current.
492
499
  """
493
500
  with open(filepath) as f:
494
501
  data = json.load(f)
495
-
502
+
496
503
  schema_version = data.get("schema_version", "1.0")
497
504
  current_version = MetricSchema.current_version()
498
-
505
+
499
506
  if schema_version != current_version:
500
507
  print(f"Migrating from schema {schema_version} to {current_version}")
501
508
  data = MetricSchema.migrate(data, from_version=schema_version)
502
-
509
+
503
510
  return data
504
511
  ```
505
512
 
@@ -128,10 +128,11 @@ ls -t history/*__0.1.0.json | head -1
128
128
  import re
129
129
  from pathlib import Path
130
130
 
131
+
131
132
  def parse_metric_filename(filename: str) -> dict:
132
133
  """
133
134
  Parse metric filename into components.
134
-
135
+
135
136
  Example: "2025_11_14_22_45_00__700e012__0.1.0.json"
136
137
  Returns: {
137
138
  "timestamp": "2025-11-14T22:45:00",
@@ -139,27 +140,32 @@ def parse_metric_filename(filename: str) -> dict:
139
140
  "pypi_version": "0.1.0",
140
141
  }
141
142
  """
142
- pattern = r'(\d{4}_\d{2}_\d{2}_\d{2}_\d{2}_\d{2})__(\w{7})__(.+)\.json'
143
+ pattern = r"(\d{4}_\d{2}_\d{2}_\d{2}_\d{2}_\d{2})__(\w{7})__(.+)\.json"
143
144
  match = re.match(pattern, filename)
144
-
145
+
145
146
  if not match:
146
147
  raise ValueError(f"Invalid filename format: {filename}")
147
-
148
+
148
149
  timestamp_str, sha, version = match.groups()
149
-
150
+
150
151
  # Convert timestamp to ISO format
151
- timestamp = timestamp_str.replace('_', '-', 2).replace('_', ':', 2).replace('_', 'T', 1)
152
-
152
+ timestamp = (
153
+ timestamp_str.replace("_", "-", 2).replace("_", ":", 2).replace("_", "T", 1)
154
+ )
155
+
153
156
  return {
154
157
  "timestamp": timestamp,
155
158
  "commit_sha": sha,
156
159
  "pypi_version": None if version == "none" else version,
157
160
  }
158
161
 
162
+
159
163
  # Usage
160
164
  for file in Path("history").glob("*.json"):
161
165
  info = parse_metric_filename(file.name)
162
- print(f"Commit {info['commit_sha']} at {info['timestamp']} (v{info['pypi_version']})")
166
+ print(
167
+ f"Commit {info['commit_sha']} at {info['timestamp']} (v{info['pypi_version']})"
168
+ )
163
169
  ```
164
170
 
165
171
  ## Metrics Schema
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "umpyre"
7
- version = "0.1.7"
7
+ version = "0.1.9"
8
8
  description = "Code analysis and quality metrics tracking system for CI/CD"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -1,6 +1,6 @@
1
1
  [metadata]
2
2
  name = umpyre
3
- version = 0.1.7
3
+ version = 0.1.9
4
4
  url = https://github.com/i2mint/umpyre
5
5
  platforms = any
6
6
  description_file = README.md
@@ -0,0 +1,77 @@
1
+ """GitBranchStorage pushes to the repository's real remote, not to the local workspace (umpyre#8)."""
2
+
3
+ import os
4
+ import subprocess
5
+
6
+ import pytest
7
+
8
+ from umpyre.storage.git_branch import GitBranchStorage
9
+
10
+
11
+ def _git(*args, cwd):
12
+ return subprocess.run(
13
+ ["git", *args], cwd=cwd, capture_output=True, text=True, check=True
14
+ ).stdout.strip()
15
+
16
+
17
+ @pytest.fixture
18
+ def workspace(tmp_path):
19
+ """A workspace whose ``origin`` is a bare repo standing in for GitHub."""
20
+ remote = tmp_path / "remote.git"
21
+ work = tmp_path / "work"
22
+ _git("init", "--bare", "-q", str(remote), cwd=tmp_path)
23
+ _git("init", "-q", str(work), cwd=tmp_path)
24
+ for k, v in (("user.name", "t"), ("user.email", "t@t")):
25
+ _git("config", k, v, cwd=work)
26
+ (work / "a.txt").write_text("a")
27
+ _git("add", ".", cwd=work)
28
+ _git("commit", "-qm", "init", cwd=work)
29
+ _git("remote", "add", "origin", str(remote), cwd=work)
30
+ return work, remote
31
+
32
+
33
+ def test_metrics_land_on_the_remote_and_accumulate(workspace):
34
+ work, remote = workspace
35
+ storage = GitBranchStorage(str(work))
36
+ storage.store_metrics({"metrics": {"n": 1}}, "a" * 40, branch="code-metrics")
37
+ # on the (bare) remote, not merely in the local workspace
38
+ assert _git("rev-parse", "--verify", "code-metrics", cwd=remote)
39
+ storage.store_metrics({"metrics": {"n": 2}}, "b" * 40, branch="code-metrics")
40
+ log = _git("log", "--format=%s", "code-metrics", cwd=remote).splitlines()
41
+ assert log == [f"Update metrics for {'b' * 7}", f"Update metrics for {'a' * 7}"]
42
+
43
+
44
+ def test_token_becomes_a_host_scoped_auth_header_not_a_url(monkeypatch):
45
+ monkeypatch.delenv("GIT_CONFIG_COUNT", raising=False)
46
+ monkeypatch.delenv("GITHUB_SERVER_URL", raising=False)
47
+ storage = GitBranchStorage.__new__(GitBranchStorage)
48
+ storage.token = "s3cret"
49
+ env = storage._git_env("https://user@github.com/org/repo.git")
50
+ assert int(env["GIT_CONFIG_COUNT"]) == 2
51
+ key = "http.https://github.com/.extraheader"
52
+ assert [env["GIT_CONFIG_KEY_0"], env["GIT_CONFIG_KEY_1"]] == [key, key]
53
+ # an empty header first clears the one actions/checkout leaves in the config
54
+ assert env["GIT_CONFIG_VALUE_0"] == ""
55
+ assert env["GIT_CONFIG_VALUE_1"].startswith("AUTHORIZATION: basic ")
56
+ assert "s3cret" not in env["GIT_CONFIG_VALUE_1"] # base64, not plain
57
+
58
+
59
+ def test_token_header_appends_to_existing_git_config_env(monkeypatch):
60
+ monkeypatch.setenv("GIT_CONFIG_COUNT", "1")
61
+ monkeypatch.delenv("GITHUB_SERVER_URL", raising=False)
62
+ storage = GitBranchStorage.__new__(GitBranchStorage)
63
+ storage.token = "t"
64
+ env = storage._git_env("https://github.com/o/r")
65
+ assert env["GIT_CONFIG_COUNT"] == "3" and "GIT_CONFIG_KEY_2" in env
66
+
67
+
68
+ def test_no_header_without_token_for_local_ssh_or_foreign_hosts(monkeypatch):
69
+ monkeypatch.delenv("GITHUB_SERVER_URL", raising=False)
70
+ storage = GitBranchStorage.__new__(GitBranchStorage)
71
+ storage.token = "t"
72
+ for url in ("/some/local/path", "git@github.com:o/r.git", None):
73
+ assert storage._git_env(url) == dict(os.environ)
74
+ monkeypatch.setenv("GITHUB_SERVER_URL", "https://github.com")
75
+ assert storage._git_env("https://evil.example/o/r") == dict(os.environ)
76
+ storage.token = None
77
+ assert storage._git_env("https://github.com/o/r") == dict(os.environ)
@@ -106,14 +106,15 @@ def cmd_collect(args):
106
106
 
107
107
  print(json.dumps(metric_data, indent=2))
108
108
  else:
109
- storage = GitBranchStorage(repo_path)
109
+ branch = args.storage_branch or config.storage_branch
110
+ storage = GitBranchStorage(repo_path, token=os.getenv("GITHUB_TOKEN"))
110
111
  storage.store_metrics(
111
112
  metric_data,
112
113
  commit_sha,
113
- branch=config.storage_branch,
114
+ branch=branch,
114
115
  formats=config.storage_formats,
115
116
  )
116
- print(f"Metrics stored to branch '{config.storage_branch}'")
117
+ print(f"Metrics stored to branch '{branch}'")
117
118
 
118
119
  print(f"Collection completed in {duration:.2f}s")
119
120
  return 0
@@ -202,6 +203,11 @@ def main():
202
203
  collect_parser.add_argument(
203
204
  "--message", help="Commit message (default: auto-detect from git)"
204
205
  )
206
+ collect_parser.add_argument(
207
+ "--storage-branch",
208
+ help="Branch to store metrics on (default: storage.branch of the config, "
209
+ "else 'code-metrics')",
210
+ )
205
211
  collect_parser.add_argument(
206
212
  "--no-store", action="store_true", help="Collect but don't store (dry-run)"
207
213
  )
@@ -341,12 +341,3 @@ def stats_of(
341
341
  put_at_the_front = [x for x in df.index if x not in put_at_the_end]
342
342
  df = df.loc[put_at_the_front + put_at_the_end]
343
343
  return df
344
-
345
-
346
- if __name__ == "__main__":
347
- from dol.util import ModuleNotFoundErrorNiceMessage
348
-
349
- with ModuleNotFoundErrorNiceMessage():
350
- import argh
351
-
352
- argh.dispatch_commands([modules_info_df, modules_info_df_stats, stats_of])
@@ -1,10 +1,13 @@
1
1
  """Git branch-based storage operations."""
2
2
 
3
+ import base64
4
+ import os
3
5
  import subprocess
4
6
  import tempfile
5
7
  from datetime import datetime
6
8
  from pathlib import Path
7
9
  from typing import Optional
10
+ from urllib.parse import urlsplit
8
11
 
9
12
  from umpyre.storage.formats import serialize_metrics
10
13
 
@@ -22,16 +25,25 @@ class GitBranchStorage:
22
25
  >>> storage.store_metrics(metrics, "abc123", branch="code-metrics") #doctest: +SKIP
23
26
  """
24
27
 
25
- def __init__(self, repo_path: str, remote: str = "origin"):
28
+ def __init__(
29
+ self, repo_path: str, remote: str = "origin", *, token: Optional[str] = None
30
+ ):
26
31
  """
27
32
  Initialize storage.
28
33
 
34
+ Metrics are pushed to the URL of ``remote`` in ``repo_path`` (e.g. GitHub),
35
+ not to ``repo_path`` itself.
36
+
29
37
  Args:
30
38
  repo_path: Path to git repository
31
39
  remote: Git remote name
40
+ token: Access token used (as an HTTP auth header scoped to the remote's
41
+ host, never written to a URL or config) to push to an https remote.
42
+ Without it, the push uses whatever credentials git already has.
32
43
  """
33
44
  self.repo_path = Path(repo_path)
34
45
  self.remote = remote
46
+ self.token = token
35
47
 
36
48
  if not (self.repo_path / ".git").exists():
37
49
  raise ValueError(f"Not a git repository: {repo_path}")
@@ -91,9 +103,46 @@ class GitBranchStorage:
91
103
  elif fmt == "csv":
92
104
  serialize_metrics(metrics, tmpdir / "metrics.csv", format="csv")
93
105
 
94
- # Commit and push
106
+ # Commit and push (to the real remote, not to the local workspace)
95
107
  self._commit_and_push(tmpdir, branch, commit_sha)
96
108
 
109
+ def _remote_url(self) -> Optional[str]:
110
+ """URL of the real remote of ``repo_path``, or ``None`` if it has none."""
111
+ result = subprocess.run(
112
+ ["git", "remote", "get-url", self.remote],
113
+ cwd=self.repo_path,
114
+ capture_output=True,
115
+ text=True,
116
+ check=False,
117
+ )
118
+ return result.stdout.strip() or None if result.returncode == 0 else None
119
+
120
+ def _git_env(self, url: Optional[str]) -> dict:
121
+ """Environment for git commands that talk to the remote (auth header, if any)."""
122
+ env = dict(os.environ)
123
+ parts = urlsplit(url or "")
124
+ host = parts.netloc.rsplit("@", 1)[-1]
125
+ server_host = urlsplit(env.get("GITHUB_SERVER_URL", "")).netloc
126
+ if (
127
+ self.token
128
+ and parts.scheme in ("http", "https")
129
+ and host
130
+ and (not server_host or host == server_host)
131
+ ):
132
+ env["GIT_TERMINAL_PROMPT"] = "0" # fail, don't wait for a password
133
+ key = f"http.{parts.scheme}://{host}/.extraheader"
134
+ credentials = base64.b64encode(f"x-access-token:{self.token}".encode())
135
+ n = int(env.get("GIT_CONFIG_COUNT") or 0)
136
+ # actions/checkout leaves its own Authorization header in the repo config,
137
+ # and GitHub rejects two (HTTP 400): an empty value clears earlier ones.
138
+ for i, value in enumerate(
139
+ ("", f"AUTHORIZATION: basic {credentials.decode()}")
140
+ ):
141
+ env[f"GIT_CONFIG_KEY_{n + i}"] = key
142
+ env[f"GIT_CONFIG_VALUE_{n + i}"] = value
143
+ env["GIT_CONFIG_COUNT"] = str(n + 2)
144
+ return env
145
+
97
146
  def _fetch_branch(self, branch: str):
98
147
  """Fetch metrics branch from remote."""
99
148
  try:
@@ -103,6 +152,7 @@ class GitBranchStorage:
103
152
  capture_output=True,
104
153
  check=False, # May not exist yet
105
154
  timeout=30,
155
+ env=self._git_env(self._remote_url()),
106
156
  )
107
157
  except subprocess.TimeoutExpired:
108
158
  pass
@@ -158,6 +208,17 @@ class GitBranchStorage:
158
208
 
159
209
  def _commit_and_push(self, work_dir: Path, branch: str, commit_sha: str):
160
210
  """Commit changes and push to remote."""
211
+ # The clone's origin is the local workspace; point it at the real remote.
212
+ remote_url = self._remote_url()
213
+ if remote_url:
214
+ subprocess.run(
215
+ ["git", "remote", "set-url", "origin", remote_url],
216
+ cwd=work_dir,
217
+ capture_output=True,
218
+ check=True,
219
+ )
220
+ env = self._git_env(remote_url)
221
+
161
222
  # Configure git
162
223
  subprocess.run(
163
224
  ["git", "config", "user.name", "umpyre-bot"],
@@ -202,21 +263,24 @@ class GitBranchStorage:
202
263
  for attempt in range(3):
203
264
  try:
204
265
  subprocess.run(
205
- ["git", "push", self.remote, branch],
266
+ ["git", "push", "origin", branch],
206
267
  cwd=work_dir,
207
268
  capture_output=True,
208
269
  check=True,
209
270
  timeout=60,
271
+ env=env,
210
272
  )
211
273
  break
212
274
  except subprocess.CalledProcessError:
213
275
  if attempt < 2:
214
276
  # Pull and try again
215
277
  subprocess.run(
216
- ["git", "pull", "--rebase", self.remote, branch],
278
+ ["git", "pull", "--rebase", "origin", branch],
217
279
  cwd=work_dir,
218
280
  capture_output=True,
219
281
  check=False,
282
+ timeout=60,
283
+ env=env,
220
284
  )
221
285
  else:
222
286
  raise
@@ -1,74 +0,0 @@
1
- name: Track Code Metrics
2
- description: Collect and store code quality metrics for CI/CD pipelines
3
-
4
- inputs:
5
- config-path:
6
- description: 'Path to umpyre config file'
7
- default: '.github/umpyre-config.yml'
8
- required: false
9
-
10
- storage-branch:
11
- description: 'Branch to store metrics'
12
- default: 'code-metrics'
13
- required: false
14
-
15
- github-token:
16
- description: 'GitHub token for API access'
17
- required: true
18
-
19
- force-run:
20
- description: 'Run even if workflow failed'
21
- default: 'false'
22
- required: false
23
-
24
- python-version:
25
- description: 'Python version to use'
26
- default: '3.10'
27
- required: false
28
-
29
- runs:
30
- using: composite
31
- steps:
32
- - name: Setup Python
33
- uses: actions/setup-python@v5
34
- with:
35
- python-version: ${{ inputs.python-version }}
36
-
37
- - name: Install umpyre and dependencies
38
- shell: bash
39
- run: |
40
- pip install umpyre coverage wily bandit interrogate
41
-
42
- - name: Collect and Store Metrics
43
- shell: bash
44
- run: |
45
- echo "::group::Collecting Code Metrics"
46
-
47
- # Set up error handling - continue even if collection fails
48
- set +e
49
-
50
- # Run umpyre collect
51
- if [ -f "${{ inputs.config-path }}" ]; then
52
- python -m umpyre.cli collect --config "${{ inputs.config-path }}"
53
- EXIT_CODE=$?
54
- else
55
- python -m umpyre.cli collect
56
- EXIT_CODE=$?
57
- fi
58
-
59
- # Report status but don't fail
60
- if [ $EXIT_CODE -eq 0 ]; then
61
- echo "✅ Metrics collection completed successfully"
62
- else
63
- echo "⚠️ Metrics collection failed with exit code $EXIT_CODE"
64
- echo "This is informational only - CI pipeline continues"
65
- fi
66
-
67
- echo "::endgroup::"
68
-
69
- # Always exit successfully to not break CI
70
- exit 0
71
-
72
- branding:
73
- icon: 'bar-chart-2'
74
- color: 'blue'
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes