Files
ServerKit/scripts/measure-repository.py
T
Juan Denis d67e5d07d0 fix: complete audit follow-ups and add regression coverage
Centralize backup schedule calculations and shared request/form flows, encode query values, and move repository finalization into its service. Add browser CI coverage, repair stale test fixtures, and provide opt-in request and build measurement tools.
2026-09-05 14:41:15 -04:00

125 lines
5.4 KiB
Python

#!/usr/bin/env python3
"""Reproduce source counts without importing the app or opening its database.
Route declarations are source inventory, not live registered endpoint counts.
Use --collect-tests with the backend Python environment for a clean-checkout
pytest collection count. Collection does not mean the tests have passed.
"""
import argparse
import ast
from datetime import datetime, timezone
import hashlib
import importlib.util
import json
import os
from pathlib import Path
import platform
import subprocess
import tempfile
ROOT = Path(__file__).resolve().parents[1]
def source_counts(files):
routes = 0
blueprints = 0
methods = 0
dynamic_methods = 0
for source in files:
tree = ast.parse(source)
for node in ast.walk(tree):
if isinstance(node, ast.Call):
name = getattr(node.func, 'id', None) or getattr(node.func, 'attr', None)
if name == 'Blueprint':
blueprints += 1
if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
continue
for decorator in node.decorator_list:
if not isinstance(decorator, ast.Call) or not isinstance(decorator.func, ast.Attribute):
continue
name = decorator.func.attr
if name not in ('route', 'get', 'post', 'put', 'patch', 'delete'):
continue
routes += 1
if name != 'route':
methods += 1
continue
keyword = next((kw for kw in decorator.keywords if kw.arg == 'methods'), None)
try:
values = ast.literal_eval(keyword.value) if keyword else ['GET']
if not isinstance(values, (list, tuple, set)) or not all(isinstance(v, str) for v in values):
raise ValueError('nonliteral methods')
methods += len(set(values) - {'HEAD', 'OPTIONS'})
except (ValueError, TypeError):
dynamic_methods += 1
return {
'core_route_declarations': routes,
'core_blueprint_declarations': blueprints,
'explicit_method_route_pairs_excluding_head_options': methods,
'route_declarations_with_dynamic_methods': dynamic_methods,
}
def collect_tests():
spec = importlib.util.spec_from_file_location('count_tests', ROOT / 'backend/tests/check_test_count.py')
collector = importlib.util.module_from_spec(spec)
spec.loader.exec_module(collector)
# conftest normally sweeps shared temp databases. A private URL avoids
# touching another developer/test process's scratch database during census.
with tempfile.TemporaryDirectory(prefix='serverkit-census-') as directory:
before = os.environ.get('TEST_DATABASE_URL')
os.environ['TEST_DATABASE_URL'] = 'sqlite:///' + (Path(directory) / 'tests.db').as_posix()
try:
return collector.collect_count()
finally:
if before is None:
os.environ.pop('TEST_DATABASE_URL', None)
else:
os.environ['TEST_DATABASE_URL'] = before
def measure(include_tests=False):
tracked = subprocess.check_output(
['git', 'ls-files', '-z'], cwd=ROOT, stderr=subprocess.DEVNULL,
).decode('utf-8').split('\0')
api_paths = sorted(p for p in tracked if p.startswith('backend/app/api/') and p.endswith('.py'))
templates = sorted(p for p in tracked if Path(p).parent.as_posix() == 'backend/templates'
and Path(p).suffix in ('.yaml', '.yml'))
fingerprint = hashlib.sha256()
for path in api_paths + templates:
fingerprint.update(path.encode('utf-8') + b'\0' + (ROOT / path).read_bytes() + b'\0')
return {
'schema_version': 1,
'measured_at_utc': datetime.now(timezone.utc).isoformat(),
'source_revision': subprocess.check_output(['git', 'rev-parse', 'HEAD'], cwd=ROOT, text=True).strip(),
'measured_source_has_uncommitted_changes': subprocess.run(
['git', 'diff', '--quiet', 'HEAD', '--', 'backend/app/api', 'backend/templates'],
cwd=ROOT, check=False,
).returncode != 0,
'measured_source_sha256': fingerprint.hexdigest(),
'scope': 'Git-tracked core API Python declarations and root-level bundled app YAML templates; excludes extensions, dependencies and generated/install copies.',
'python_version': platform.python_version(),
'platform': platform.system(),
**source_counts([(ROOT / path).read_text(encoding='utf-8-sig') for path in api_paths]),
'core_api_source_files': len(api_paths),
'bundled_app_templates': len(templates),
'backend_tests_clean_collected': collect_tests() if include_tests else None,
'collection_scope': 'backend/tests, SERVERKIT_CLEAN_COLLECT=1; collected cases, not passed tests',
'runtime_memory_bytes': None,
'container_image_bytes': None,
}
if __name__ == '__main__':
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--collect-tests', action='store_true')
parser.add_argument('--output', type=Path)
args = parser.parse_args()
report = json.dumps(measure(args.collect_tests), indent=2) + '\n'
if args.output:
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(report, encoding='utf-8')
else:
print(report, end='')