mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-08-28 05:25:25 +00:00
266 lines
9.1 KiB
TypeScript
266 lines
9.1 KiB
TypeScript
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
import fs from 'node:fs/promises';
|
|
import path from 'node:path';
|
|
import os from 'node:os';
|
|
import { extractPythonWorkspaceLinks } from '../../../src/core/group/extractors/python-workspace-extractor.js';
|
|
|
|
describe('PythonWorkspaceExtractor', () => {
|
|
let tmpDir: string;
|
|
|
|
beforeEach(async () => {
|
|
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-py-ws-'));
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
});
|
|
|
|
async function writeFile(relPath: string, content: string) {
|
|
const absPath = path.join(tmpDir, relPath);
|
|
await fs.mkdir(path.dirname(absPath), { recursive: true });
|
|
await fs.writeFile(absPath, content, 'utf-8');
|
|
}
|
|
|
|
it('discovers cross-package imports via pyproject.toml', async () => {
|
|
await writeFile(
|
|
'models/pyproject.toml',
|
|
'[project]\nname = "shared-models"\nversion = "0.1.0"\ndependencies = []\n',
|
|
);
|
|
await writeFile('models/shared_models/__init__.py', 'class Schema: pass\n');
|
|
|
|
await writeFile(
|
|
'api/pyproject.toml',
|
|
'[project]\nname = "api-server"\nversion = "0.1.0"\ndependencies = [\n "shared-models>=0.1.0",\n]\n',
|
|
);
|
|
await writeFile('api/api_server/main.py', 'from shared_models import Schema\n');
|
|
|
|
const repos = { models: 'shared-models', api: 'api-server' };
|
|
const repoPaths = new Map([
|
|
['models', path.join(tmpDir, 'models')],
|
|
['api', path.join(tmpDir, 'api')],
|
|
]);
|
|
|
|
const result = await extractPythonWorkspaceLinks(repos, repoPaths);
|
|
|
|
expect(result.links).toHaveLength(1);
|
|
expect(result.links[0]).toEqual({
|
|
from: 'models',
|
|
to: 'api',
|
|
type: 'custom',
|
|
contract: 'shared-models::Schema',
|
|
role: 'provider',
|
|
});
|
|
});
|
|
|
|
it('does not emit contracts from a nested Python virtual environment', async () => {
|
|
await writeFile(
|
|
'provider/pyproject.toml',
|
|
'[project]\nname = "provider"\nversion = "0.1.0"\ndependencies = []\n',
|
|
);
|
|
await writeFile('provider/provider/__init__.py', 'class SecretClient: pass\n');
|
|
|
|
await writeFile(
|
|
'consumer/pyproject.toml',
|
|
'[project]\nname = "consumer"\nversion = "0.1.0"\ndependencies = ["provider"]\n',
|
|
);
|
|
await writeFile('consumer/backend/env/pyvenv.cfg', 'home = python\n');
|
|
await writeFile('consumer/backend/env/leaked.py', 'from provider import SecretClient\n');
|
|
|
|
const repos = { provider: 'provider', consumer: 'consumer' };
|
|
const repoPaths = new Map([
|
|
['provider', path.join(tmpDir, 'provider')],
|
|
['consumer', path.join(tmpDir, 'consumer')],
|
|
]);
|
|
|
|
const result = await extractPythonWorkspaceLinks(repos, repoPaths);
|
|
|
|
expect(result.links).toHaveLength(0);
|
|
});
|
|
|
|
it('discovers imports via setup.py', async () => {
|
|
await writeFile(
|
|
'core/setup.py',
|
|
"from setuptools import setup\nsetup(name='mycore', version='1.0', install_requires=[])\n",
|
|
);
|
|
await writeFile('core/mycore/__init__.py', 'class Engine: pass\n');
|
|
|
|
await writeFile(
|
|
'app/setup.py',
|
|
"from setuptools import setup\nsetup(name='myapp', version='1.0', install_requires=['mycore>=1.0'])\n",
|
|
);
|
|
await writeFile('app/myapp/run.py', 'from mycore import Engine\n');
|
|
|
|
const repos = { core: 'mycore', app: 'myapp' };
|
|
const repoPaths = new Map([
|
|
['core', path.join(tmpDir, 'core')],
|
|
['app', path.join(tmpDir, 'app')],
|
|
]);
|
|
|
|
const result = await extractPythonWorkspaceLinks(repos, repoPaths);
|
|
|
|
expect(result.links).toHaveLength(1);
|
|
expect(result.links[0].contract).toBe('mycore::Engine');
|
|
});
|
|
|
|
it('handles hyphenated package names (normalized to underscore in imports)', async () => {
|
|
await writeFile(
|
|
'lib/pyproject.toml',
|
|
'[project]\nname = "my-utils"\nversion = "0.1.0"\ndependencies = []\n',
|
|
);
|
|
await writeFile('lib/my_utils/__init__.py', 'class Helper: pass\n');
|
|
|
|
await writeFile(
|
|
'svc/pyproject.toml',
|
|
'[project]\nname = "my-service"\nversion = "0.1.0"\ndependencies = [\n "my-utils",\n]\n',
|
|
);
|
|
await writeFile('svc/my_service/main.py', 'from my_utils import Helper\n');
|
|
|
|
const repos = { lib: 'my-utils', svc: 'my-service' };
|
|
const repoPaths = new Map([
|
|
['lib', path.join(tmpDir, 'lib')],
|
|
['svc', path.join(tmpDir, 'svc')],
|
|
]);
|
|
|
|
const result = await extractPythonWorkspaceLinks(repos, repoPaths);
|
|
|
|
expect(result.links).toHaveLength(1);
|
|
expect(result.links[0].contract).toBe('my-utils::Helper');
|
|
});
|
|
|
|
it('handles submodule imports (from pkg.sub import Class)', async () => {
|
|
await writeFile(
|
|
'lib/pyproject.toml',
|
|
'[project]\nname = "datalib"\nversion = "0.1.0"\ndependencies = []\n',
|
|
);
|
|
await writeFile('lib/datalib/models.py', 'class Record: pass\n');
|
|
|
|
await writeFile(
|
|
'app/pyproject.toml',
|
|
'[project]\nname = "myapp"\nversion = "0.1.0"\ndependencies = [\n "datalib",\n]\n',
|
|
);
|
|
await writeFile('app/myapp/main.py', 'from datalib.models import Record\n');
|
|
|
|
const repos = { lib: 'datalib', app: 'myapp' };
|
|
const repoPaths = new Map([
|
|
['lib', path.join(tmpDir, 'lib')],
|
|
['app', path.join(tmpDir, 'app')],
|
|
]);
|
|
|
|
const result = await extractPythonWorkspaceLinks(repos, repoPaths);
|
|
|
|
expect(result.links).toHaveLength(1);
|
|
expect(result.links[0].contract).toBe('datalib::Record');
|
|
});
|
|
|
|
it('ignores snake_case imports (functions, not types)', async () => {
|
|
await writeFile(
|
|
'lib/pyproject.toml',
|
|
'[project]\nname = "utils"\nversion = "0.1.0"\ndependencies = []\n',
|
|
);
|
|
await writeFile('lib/utils/__init__.py', 'def helper(): pass\nclass Config: pass\n');
|
|
|
|
await writeFile(
|
|
'app/pyproject.toml',
|
|
'[project]\nname = "myapp"\nversion = "0.1.0"\ndependencies = [\n "utils",\n]\n',
|
|
);
|
|
await writeFile('app/myapp/main.py', 'from utils import helper, Config\n');
|
|
|
|
const repos = { lib: 'utils', app: 'myapp' };
|
|
const repoPaths = new Map([
|
|
['lib', path.join(tmpDir, 'lib')],
|
|
['app', path.join(tmpDir, 'app')],
|
|
]);
|
|
|
|
const result = await extractPythonWorkspaceLinks(repos, repoPaths);
|
|
|
|
expect(result.links).toHaveLength(1);
|
|
expect(result.links[0].contract).toBe('utils::Config');
|
|
});
|
|
|
|
it('skips repos without Python manifest', async () => {
|
|
await writeFile('js-app/package.json', '{"name": "js-app"}');
|
|
|
|
const repos = { app: 'js-app' };
|
|
const repoPaths = new Map([['app', path.join(tmpDir, 'js-app')]]);
|
|
|
|
const result = await extractPythonWorkspaceLinks(repos, repoPaths);
|
|
|
|
expect(result.links).toHaveLength(0);
|
|
expect(result.discoveredPackages.size).toBe(0);
|
|
});
|
|
|
|
it('deduplicates identical imports from multiple files', async () => {
|
|
await writeFile(
|
|
'lib/pyproject.toml',
|
|
'[project]\nname = "shared"\nversion = "0.1.0"\ndependencies = []\n',
|
|
);
|
|
await writeFile('lib/shared/__init__.py', 'class Config: pass\n');
|
|
|
|
await writeFile(
|
|
'app/pyproject.toml',
|
|
'[project]\nname = "myapp"\nversion = "0.1.0"\ndependencies = [\n "shared",\n]\n',
|
|
);
|
|
await writeFile('app/myapp/a.py', 'from shared import Config\n');
|
|
await writeFile('app/myapp/b.py', 'from shared import Config\n');
|
|
|
|
const repos = { lib: 'shared', app: 'myapp' };
|
|
const repoPaths = new Map([
|
|
['lib', path.join(tmpDir, 'lib')],
|
|
['app', path.join(tmpDir, 'app')],
|
|
]);
|
|
|
|
const result = await extractPythonWorkspaceLinks(repos, repoPaths);
|
|
|
|
expect(result.links).toHaveLength(1);
|
|
});
|
|
|
|
it('handles aliased imports (from pkg import Foo as Bar)', async () => {
|
|
await writeFile(
|
|
'lib/pyproject.toml',
|
|
'[project]\nname = "models"\nversion = "0.1.0"\ndependencies = []\n',
|
|
);
|
|
await writeFile('lib/models/__init__.py', 'class Entity: pass\n');
|
|
|
|
await writeFile(
|
|
'app/pyproject.toml',
|
|
'[project]\nname = "myapp"\nversion = "0.1.0"\ndependencies = [\n "models",\n]\n',
|
|
);
|
|
await writeFile('app/myapp/main.py', 'from models import Entity as BaseEntity\n');
|
|
|
|
const repos = { lib: 'models', app: 'myapp' };
|
|
const repoPaths = new Map([
|
|
['lib', path.join(tmpDir, 'lib')],
|
|
['app', path.join(tmpDir, 'app')],
|
|
]);
|
|
|
|
const result = await extractPythonWorkspaceLinks(repos, repoPaths);
|
|
|
|
expect(result.links).toHaveLength(1);
|
|
expect(result.links[0].contract).toBe('models::Entity');
|
|
});
|
|
|
|
it('reads optional-dependencies from pyproject.toml', async () => {
|
|
await writeFile(
|
|
'lib/pyproject.toml',
|
|
'[project]\nname = "extras"\nversion = "0.1.0"\ndependencies = []\n',
|
|
);
|
|
await writeFile('lib/extras/__init__.py', 'class Plugin: pass\n');
|
|
|
|
await writeFile(
|
|
'app/pyproject.toml',
|
|
'[project]\nname = "myapp"\nversion = "0.1.0"\ndependencies = []\n\n[project.optional-dependencies]\ndev = [\n "extras>=0.1",\n]\n',
|
|
);
|
|
await writeFile('app/myapp/main.py', 'from extras import Plugin\n');
|
|
|
|
const repos = { lib: 'extras', app: 'myapp' };
|
|
const repoPaths = new Map([
|
|
['lib', path.join(tmpDir, 'lib')],
|
|
['app', path.join(tmpDir, 'app')],
|
|
]);
|
|
|
|
const result = await extractPythonWorkspaceLinks(repos, repoPaths);
|
|
|
|
expect(result.links).toHaveLength(1);
|
|
expect(result.links[0].contract).toBe('extras::Plugin');
|
|
});
|
|
});
|