mirror of
https://github.com/jhd3197/ServerKit.git
synced 2026-10-23 03:27:43 +09:00
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.
125 lines
5.4 KiB
Python
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='')
|