apiforge-analyzer 1.3.6 → 1.3.8

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.
@@ -0,0 +1,171 @@
1
+ /**
2
+ * FastAPI / Flask / Django (+ DRF) analyzer
3
+ *
4
+ * Python apps can't be handed to analyze() as a JS object, so this runs the
5
+ * user's own interpreter (their venv, so their dependencies resolve), imports
6
+ * the app, and reads the framework's live route table — see
7
+ * python-introspect.ts. Nothing is injected into the user's code and no server
8
+ * is started. Returns the same Route[] as every other analyzer; enrichment and
9
+ * upload happen in index.ts / uploader.ts exactly as for Express or Fastify.
10
+ *
11
+ * The `npx apiforge` CLI instead works like its Node flow: it injects a snippet
12
+ * into the file returned in PythonAppCandidate.file and runs the user's own
13
+ * start command (see pythonFlow in cli.ts).
14
+ */
15
+ import * as fs from 'fs';
16
+ import * as path from 'path';
17
+ import { spawn } from 'child_process';
18
+ import { PYTHON_INTROSPECT_SCRIPT, ROUTES_MARKER } from './python-introspect.js';
19
+ const SKIP_DIRS = new Set([
20
+ '.git', '.venv', 'venv', 'env', '.env', 'node_modules', '__pycache__', 'site-packages',
21
+ '.tox', '.mypy_cache', '.pytest_cache', 'dist', 'build', 'migrations',
22
+ ]);
23
+ /**
24
+ * Analyze a Python web app's routes
25
+ */
26
+ export async function analyzePython(options) {
27
+ let appDir = options.appDir || process.cwd();
28
+ let { app, factory, djangoSettings } = options;
29
+ // Fill in whatever the caller left out from the project layout.
30
+ if (options.framework === 'django' ? !app && !djangoSettings && !process.env.DJANGO_SETTINGS_MODULE : !app) {
31
+ const [candidate] = findPythonApps(options.framework, appDir);
32
+ if (!candidate) {
33
+ throw new Error(options.framework === 'django'
34
+ ? 'Django settings not found — pass djangoSettings (e.g. "mysite.settings")'
35
+ : `${options.framework} app not found — pass pythonApp (e.g. "main:app")`);
36
+ }
37
+ ;
38
+ ({ app, factory, djangoSettings, appDir } = candidate);
39
+ }
40
+ const pythonPath = options.pythonPath || findPythonInterpreter(appDir);
41
+ const stdout = await runPython(pythonPath, appDir, {
42
+ framework: options.framework, app, factory, djangoSettings, appDir,
43
+ }, options.timeoutMs ?? 60000);
44
+ const line = stdout.split(/\r?\n/).find(l => l.startsWith(ROUTES_MARKER));
45
+ if (!line)
46
+ throw new Error('Python analyzer produced no output');
47
+ return JSON.parse(line.slice(ROUTES_MARKER.length));
48
+ }
49
+ function runPython(pythonPath, cwd, opts, timeoutMs) {
50
+ return new Promise((resolve, reject) => {
51
+ const child = spawn(pythonPath, ['-'], {
52
+ cwd,
53
+ stdio: ['pipe', 'pipe', 'pipe'],
54
+ env: {
55
+ ...process.env,
56
+ APIFORGE_PY_OPTIONS: JSON.stringify(opts),
57
+ PYTHONIOENCODING: 'utf-8',
58
+ // Don't leave __pycache__ folders behind in the user's project.
59
+ PYTHONDONTWRITEBYTECODE: '1',
60
+ },
61
+ });
62
+ let stdout = '';
63
+ let stderr = '';
64
+ child.stdout.on('data', (chunk) => { stdout += chunk.toString('utf-8'); });
65
+ child.stderr.on('data', (chunk) => { stderr += chunk.toString('utf-8'); });
66
+ const timer = setTimeout(() => {
67
+ child.kill();
68
+ reject(new Error(`Python analyzer timed out after ${Math.round(timeoutMs / 1000)}s`));
69
+ }, timeoutMs);
70
+ child.on('error', (err) => {
71
+ clearTimeout(timer);
72
+ reject(new Error(err.code === 'ENOENT'
73
+ ? `Python interpreter not found: "${pythonPath}" — activate your virtualenv or set APIFORGE_PYTHON`
74
+ : err.message));
75
+ });
76
+ child.on('close', code => {
77
+ clearTimeout(timer);
78
+ if (code === 0)
79
+ return resolve(stdout);
80
+ // The last traceback line is the actual error (ModuleNotFoundError: ..., etc.)
81
+ const lastLine = stderr.trim().split(/\r?\n/).pop() || `exited with code ${code}`;
82
+ reject(new Error(`Python analyzer failed: ${lastLine}`));
83
+ });
84
+ child.stdin.end(PYTHON_INTROSPECT_SCRIPT);
85
+ });
86
+ }
87
+ /**
88
+ * The interpreter that has the project's dependencies installed:
89
+ * APIFORGE_PYTHON → active virtualenv → a venv inside the project → system python.
90
+ */
91
+ export function findPythonInterpreter(projectDir = process.cwd()) {
92
+ if (process.env.APIFORGE_PYTHON)
93
+ return process.env.APIFORGE_PYTHON;
94
+ const inVenv = (venvDir) => process.platform === 'win32'
95
+ ? path.join(venvDir, 'Scripts', 'python.exe')
96
+ : path.join(venvDir, 'bin', 'python');
97
+ const candidates = [
98
+ process.env.VIRTUAL_ENV,
99
+ ...['.venv', 'venv', 'env'].map(dir => path.join(projectDir, dir)),
100
+ ].filter(Boolean).map(dir => inVenv(dir));
101
+ return candidates.find(p => fs.existsSync(p)) || (process.platform === 'win32' ? 'python' : 'python3');
102
+ }
103
+ /**
104
+ * Find where the app object (FastAPI/Flask) or settings module (Django) lives.
105
+ */
106
+ export function findPythonApps(framework, projectDir = process.cwd()) {
107
+ const files = listPythonFiles(projectDir, 3);
108
+ if (framework === 'django') {
109
+ const candidates = [];
110
+ for (const file of files.filter(f => path.basename(f) === 'manage.py')) {
111
+ const content = fs.readFileSync(file, 'utf-8');
112
+ const match = content.match(/DJANGO_SETTINGS_MODULE['"]\s*,\s*['"]([\w.]+)['"]/);
113
+ if (match) {
114
+ const appDir = path.dirname(file);
115
+ // The CLI injects into manage.py itself: every runserver goes through it, whereas
116
+ // wsgi.py is only loaded when settings.WSGI_APPLICATION points at it.
117
+ candidates.push({ label: `${match[1]} (${path.relative(projectDir, file) || 'manage.py'})`, djangoSettings: match[1], appDir, file });
118
+ }
119
+ }
120
+ return candidates;
121
+ }
122
+ const ctor = framework === 'fastapi' ? 'FastAPI' : 'Flask';
123
+ const instanceRe = new RegExp(`^(\\w+)\\s*(?::\\s*\\w+\\s*)?=\\s*(?:\\w+\\.)?${ctor}\\(`, 'gm');
124
+ const candidates = [];
125
+ for (const file of files) {
126
+ const content = fs.readFileSync(file, 'utf-8');
127
+ if (!content.includes(ctor))
128
+ continue;
129
+ // src/ layout: import from inside src/ so the app's own absolute imports resolve
130
+ const rel = path.relative(projectDir, file).split(path.sep);
131
+ const appDir = rel[0] === 'src' ? path.join(projectDir, 'src') : projectDir;
132
+ if (rel[0] === 'src')
133
+ rel.shift();
134
+ const module = rel.join('.').replace(/\.py$/, '').replace(/\.__init__$/, '');
135
+ for (const match of content.matchAll(instanceRe)) {
136
+ candidates.push({ label: `${module}:${match[1]}`, app: `${module}:${match[1]}`, appDir, file });
137
+ }
138
+ if (framework === 'flask' && !content.match(instanceRe)) {
139
+ const factory = content.match(/^def\s+(create_app|make_app)\s*\(/m);
140
+ if (factory)
141
+ candidates.push({ label: `${module}:${factory[1]}()`, app: `${module}:${factory[1]}`, factory: true, appDir, file });
142
+ }
143
+ }
144
+ // Top-level entry files first: main.py / app.py beat a FastAPI() in some sub-module
145
+ return candidates.sort((a, b) => depth(a.app) - depth(b.app));
146
+ }
147
+ function depth(app) {
148
+ return (app || '').split(':')[0].split('.').length;
149
+ }
150
+ function listPythonFiles(dir, maxDepth, level = 0) {
151
+ let entries;
152
+ try {
153
+ entries = fs.readdirSync(dir, { withFileTypes: true });
154
+ }
155
+ catch {
156
+ return [];
157
+ }
158
+ const files = [];
159
+ for (const entry of entries) {
160
+ const full = path.join(dir, entry.name);
161
+ if (entry.isDirectory()) {
162
+ if (level < maxDepth && !SKIP_DIRS.has(entry.name) && !entry.name.startsWith('.')) {
163
+ files.push(...listPythonFiles(full, maxDepth, level + 1));
164
+ }
165
+ }
166
+ else if (entry.isFile() && entry.name.endsWith('.py')) {
167
+ files.push(full);
168
+ }
169
+ }
170
+ return files;
171
+ }
package/dist/esm/index.js CHANGED
@@ -10,6 +10,7 @@ import { analyzeVite } from './frameworks/vite.js';
10
10
  import { analyzeReactRouter } from './frameworks/react-router.js';
11
11
  import { analyzeNestJS } from './frameworks/nestjs.js';
12
12
  import { analyzeFastify } from './frameworks/fastify.js';
13
+ import { analyzePython } from './frameworks/python.js';
13
14
  import { detectFramework, SUPPORTED_FRAMEWORKS } from './detect.js';
14
15
  import { exportToJSON } from './export.js';
15
16
  import { uploadRoutes, writeStatusFile } from './uploader.js';
@@ -167,6 +168,18 @@ export async function analyze(app, options) {
167
168
  case 'react-router':
168
169
  routes = await analyzeReactRouter(options.nextDir || 'src/routes.tsx');
169
170
  break;
171
+ case 'fastapi':
172
+ case 'flask':
173
+ case 'django':
174
+ routes = await analyzePython({
175
+ framework: framework,
176
+ app: options.pythonApp,
177
+ factory: options.pythonFactory,
178
+ pythonPath: options.pythonPath,
179
+ djangoSettings: options.djangoSettings,
180
+ appDir: options.nextDir,
181
+ });
182
+ break;
170
183
  // No default: unsupported frameworks are rejected above, before this
171
184
  // try block, so a thrown error here isn't silently swallowed into `[]`.
172
185
  }
@@ -212,7 +225,7 @@ export async function analyze(app, options) {
212
225
  return routes;
213
226
  }
214
227
  // Re-exports for library consumers
215
- export { analyzeExpress, analyzeNextPages, analyzeNextApp, analyzeVite, analyzeReactRouter, analyzeNestJS, analyzeFastify };
228
+ export { analyzeExpress, analyzeNextPages, analyzeNextApp, analyzeVite, analyzeReactRouter, analyzeNestJS, analyzeFastify, analyzePython };
216
229
  export { exportToJSON } from './export.js';
217
230
  export { uploadRoutes } from './uploader.js';
218
231
  export { detectFramework } from './detect.js';
@@ -226,6 +239,7 @@ export default {
226
239
  analyzeVite,
227
240
  analyzeReactRouter,
228
241
  analyzeNestJS,
242
+ analyzePython,
229
243
  exportToJSON,
230
244
  uploadRoutes,
231
245
  detectFramework,
@@ -1,4 +1,4 @@
1
- export type Framework = 'express' | 'fastify' | 'koa' | 'hapi' | 'nestjs' | 'nextjs-pages' | 'nextjs-app' | 'vite' | 'react-router' | 'unknown';
1
+ export type Framework = 'express' | 'fastify' | 'koa' | 'hapi' | 'nestjs' | 'nextjs-pages' | 'nextjs-app' | 'vite' | 'react-router' | 'fastapi' | 'flask' | 'django' | 'unknown';
2
2
  export interface FrameworkInfo {
3
3
  name: Framework;
4
4
  version?: string;
@@ -13,3 +13,14 @@ export interface FrameworkInfo {
13
13
  */
14
14
  export declare const SUPPORTED_FRAMEWORKS: ReadonlySet<Framework>;
15
15
  export declare function detectFramework(app?: any): FrameworkInfo;
16
+ /**
17
+ * Detect a Python web framework from manage.py / dependency files.
18
+ * Python apps are never passed to analyze() as an object, so this is file-based only.
19
+ */
20
+ export declare function detectPythonFramework(dir?: string): 'fastapi' | 'flask' | 'django' | null;
21
+ /**
22
+ * Read a text file honouring a UTF-16 BOM. Windows PowerShell 5.1's
23
+ * `pip freeze > requirements.txt` writes UTF-16 LE, which read as UTF-8
24
+ * interleaves NUL bytes and hides every package name.
25
+ */
26
+ export declare function readTextFile(path: string): string;
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Python side of the FastAPI / Flask / Django analyzers (see python.ts).
3
+ *
4
+ * Reads the framework's live route table — the Python equivalent of walking
5
+ * app._router.stack in express.ts. Runs in one of two modes:
6
+ * - analyze(): piped to the user's interpreter on stdin; imports the app and
7
+ * prints raw Route objects as one JSON line prefixed with ROUTES_MARKER.
8
+ * - CLI: exec'd by the snippet the CLI injects into the user's entry file
9
+ * (APIFORGE_APP set), inside their own running server; writes the routes
10
+ * to APIFORGE_STATUS_FILE, like the Node snippet's collection mode.
11
+ * Enrichment and upload stay in TS either way.
12
+ *
13
+ * Kept as an embedded string (not a .py file) so the existing tsc-only build
14
+ * ships it without any copy step. String.raw keeps regex backslashes intact —
15
+ * the Python below must not contain backticks or a dollar-brace sequence.
16
+ */
17
+ export declare const ROUTES_MARKER = "__APIFORGE_ROUTES__";
18
+ export declare const PYTHON_INTROSPECT_SCRIPT: string;
@@ -0,0 +1,52 @@
1
+ /**
2
+ * FastAPI / Flask / Django (+ DRF) analyzer
3
+ *
4
+ * Python apps can't be handed to analyze() as a JS object, so this runs the
5
+ * user's own interpreter (their venv, so their dependencies resolve), imports
6
+ * the app, and reads the framework's live route table — see
7
+ * python-introspect.ts. Nothing is injected into the user's code and no server
8
+ * is started. Returns the same Route[] as every other analyzer; enrichment and
9
+ * upload happen in index.ts / uploader.ts exactly as for Express or Fastify.
10
+ *
11
+ * The `npx apiforge` CLI instead works like its Node flow: it injects a snippet
12
+ * into the file returned in PythonAppCandidate.file and runs the user's own
13
+ * start command (see pythonFlow in cli.ts).
14
+ */
15
+ import { Route } from '../export.js';
16
+ export type PythonFramework = 'fastapi' | 'flask' | 'django';
17
+ export interface PythonAnalyzerOptions {
18
+ framework: PythonFramework;
19
+ /** App import path, e.g. 'main:app', 'app:create_app' (with factory), 'mysite.wsgi:application'. */
20
+ app?: string;
21
+ /** Call `app` as a factory (Flask's create_app pattern). */
22
+ factory?: boolean;
23
+ /** Django settings module, e.g. 'mysite.settings'. Defaults to DJANGO_SETTINGS_MODULE / manage.py. */
24
+ djangoSettings?: string;
25
+ /** Interpreter to run. Defaults to findPythonInterpreter(). */
26
+ pythonPath?: string;
27
+ /** Directory the app is imported from. Defaults to process.cwd(). */
28
+ appDir?: string;
29
+ timeoutMs?: number;
30
+ }
31
+ export interface PythonAppCandidate {
32
+ label: string;
33
+ app?: string;
34
+ factory?: boolean;
35
+ djangoSettings?: string;
36
+ appDir: string;
37
+ /** File the CLI injects its snippet into: the module defining the app, or Django's manage.py. */
38
+ file?: string;
39
+ }
40
+ /**
41
+ * Analyze a Python web app's routes
42
+ */
43
+ export declare function analyzePython(options: PythonAnalyzerOptions): Promise<Route[]>;
44
+ /**
45
+ * The interpreter that has the project's dependencies installed:
46
+ * APIFORGE_PYTHON → active virtualenv → a venv inside the project → system python.
47
+ */
48
+ export declare function findPythonInterpreter(projectDir?: string): string;
49
+ /**
50
+ * Find where the app object (FastAPI/Flask) or settings module (Django) lives.
51
+ */
52
+ export declare function findPythonApps(framework: PythonFramework, projectDir?: string): PythonAppCandidate[];
@@ -8,6 +8,7 @@ import { analyzeVite } from './frameworks/vite.js';
8
8
  import { analyzeReactRouter } from './frameworks/react-router.js';
9
9
  import { analyzeNestJS } from './frameworks/nestjs.js';
10
10
  import { analyzeFastify } from './frameworks/fastify.js';
11
+ import { analyzePython } from './frameworks/python.js';
11
12
  import { detectFramework } from './detect.js';
12
13
  import { exportToJSON } from './export.js';
13
14
  import { uploadRoutes } from './uploader.js';
@@ -41,9 +42,17 @@ export interface AnalyzerOptions {
41
42
  }>;
42
43
  /** JSON file keyed by "METHOD /path" carrying summary/description/tags/deprecated/errors overrides for what can't be inferred. */
43
44
  annotationsPath?: string;
45
+ /** App import path, e.g. 'main:app' or 'app:create_app'. Auto-detected when omitted. */
46
+ pythonApp?: string;
47
+ /** Treat pythonApp as an app factory and call it (Flask create_app pattern). */
48
+ pythonFactory?: boolean;
49
+ /** Python interpreter with the app's dependencies. Defaults to APIFORGE_PYTHON / the project's venv. */
50
+ pythonPath?: string;
51
+ /** Django settings module, e.g. 'mysite.settings'. Defaults to the one manage.py sets. */
52
+ djangoSettings?: string;
44
53
  }
45
54
  export declare function analyze(app: any, options: AnalyzerOptions): Promise<any[]>;
46
- export { analyzeExpress, analyzeNextPages, analyzeNextApp, analyzeVite, analyzeReactRouter, analyzeNestJS, analyzeFastify };
55
+ export { analyzeExpress, analyzeNextPages, analyzeNextApp, analyzeVite, analyzeReactRouter, analyzeNestJS, analyzeFastify, analyzePython };
47
56
  export { exportToJSON } from './export.js';
48
57
  export { uploadRoutes } from './uploader.js';
49
58
  export type { Route } from './export.js';
@@ -58,6 +67,7 @@ declare const _default: {
58
67
  analyzeVite: typeof analyzeVite;
59
68
  analyzeReactRouter: typeof analyzeReactRouter;
60
69
  analyzeNestJS: typeof analyzeNestJS;
70
+ analyzePython: typeof analyzePython;
61
71
  exportToJSON: typeof exportToJSON;
62
72
  uploadRoutes: typeof uploadRoutes;
63
73
  detectFramework: typeof detectFramework;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "apiforge-analyzer",
3
- "version": "1.3.6",
3
+ "version": "1.3.8",
4
4
  "description": "Universal API documentation generator for Express, Next.js, Vite, React Router, and Node.js",
5
5
  "type": "module",
6
6
  "main": "./dist/cjs/index.js",