Skip to content
Merged
Show file tree
Hide file tree
Changes from 13 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/wide-mirrors-invent.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@generaltranslation/python-extractor': minor
'gt': minor
---

feat: add python support for registration
2 changes: 2 additions & 0 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@
"esbuild": "^0.27.2",
"fast-glob": "^3.3.3",
"fast-json-stable-stringify": "^2.1.0",
"@generaltranslation/python-extractor": "workspace:*",
"generaltranslation": "workspace:*",
"gt-remark": "workspace:*",
"html-entities": "^2.6.0",
Expand All @@ -132,6 +133,7 @@
"remark-parse": "^11.0.0",
"remark-stringify": "^11.0.0",
"resolve": "^1.22.10",
"smol-toml": "^1.3.1",
"tsconfig-paths": "^4.2.0",
"unified": "^11.0.5",
"unist-util-visit": "^5.0.0",
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/cli/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ import {
getAgentInstructions,
appendAgentInstructions,
} from '../setup/agentInstructions.js';
import { determineLibrary } from '../fs/determineFramework.js';
import { determineLibrary } from '../fs/determineFramework/index.js';
import { INLINE_LIBRARIES } from '../types/libraries.js';
import { handleEnqueue } from './commands/enqueue.js';

Expand Down
6 changes: 5 additions & 1 deletion packages/cli/src/cli/inline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,10 +193,14 @@ function fallbackToGtReact(library: SupportedLibraries): InlineLibrary {
Libraries.GT_NEXT,
Libraries.GT_NODE,
Libraries.GT_REACT_NATIVE,
Libraries.GT_FLASK,
Libraries.GT_FASTAPI,
].includes(library as Libraries)
? (library as
| typeof Libraries.GT_NEXT
| typeof Libraries.GT_NODE
| typeof Libraries.GT_REACT_NATIVE)
| typeof Libraries.GT_REACT_NATIVE
| typeof Libraries.GT_FLASK
| typeof Libraries.GT_FASTAPI)
: Libraries.GT_REACT;
}
17 changes: 17 additions & 0 deletions packages/cli/src/cli/python.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { Command } from 'commander';
import { SupportedLibraries } from '../types/index.js';
import { InlineCLI } from './inline.js';
import { PythonLibrary } from '../types/libraries.js';

/**
* CLI tool for managing translations with gt-flask and gt-fastapi
*/
export class PythonCLI extends InlineCLI {
constructor(
command: Command,
library: PythonLibrary,
additionalModules?: SupportedLibraries[]
) {
super(command, library, additionalModules);
}
}
15 changes: 13 additions & 2 deletions packages/cli/src/config/generateSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,17 @@ export const DEFAULT_SRC_PATTERNS = [
'components/**/*.{js,jsx,ts,tsx}',
];

export const DEFAULT_PYTHON_SRC_PATTERNS = ['**/*.py'];
export const DEFAULT_PYTHON_SRC_EXCLUDES = [
'venv/**',
'.venv/**',
'__pycache__/**',
'**/migrations/**',
'**/tests/**',
'**/test_*.py',
'**/*_test.py',
];

/**
* Generates settings from any
* @param flags - The CLI flags to generate settings from
Expand Down Expand Up @@ -158,8 +169,8 @@ export async function generateSettings(
// Add publish if not provided
mergedOptions.publish = (gtConfig.publish || flags.publish) ?? false;

// Populate src if not provided
mergedOptions.src = mergedOptions.src || DEFAULT_SRC_PATTERNS;
// Don't default src here — each pipeline (JS/Python) has its own defaults.
// Only set src if the user explicitly provided it via flags or config.

// Resolve all glob patterns in the files object
const compositePatterns = [
Expand Down
111 changes: 111 additions & 0 deletions packages/cli/src/extraction/__tests__/mapToUpdates.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import { describe, it, expect } from 'vitest';
import { mapExtractionResultsToUpdates } from '../mapToUpdates.js';
import type { ExtractionResult } from '@generaltranslation/python-extractor';

describe('mapExtractionResultsToUpdates', () => {
it('maps empty results to empty updates', () => {
const updates = mapExtractionResultsToUpdates([]);
expect(updates).toEqual([]);
});

it('maps single result with all metadata fields', () => {
const results: ExtractionResult[] = [
{
dataFormat: 'ICU',
source: 'Hello, {name}!',
metadata: {
id: 'greeting',
context: 'casual',
maxChars: 100,
filePaths: ['app.py'],
staticId: 'static-1',
},
},
];

const updates = mapExtractionResultsToUpdates(results);

expect(updates).toHaveLength(1);
expect(updates[0]).toEqual({
dataFormat: 'ICU',
source: 'Hello, {name}!',
metadata: {
id: 'greeting',
context: 'casual',
maxChars: 100,
filePaths: ['app.py'],
staticId: 'static-1',
},
});
});

it('passes through dataFormat correctly', () => {
const results: ExtractionResult[] = [
{
dataFormat: 'JSX',
source: '<p>Hello</p>',
metadata: {},
},
];

const updates = mapExtractionResultsToUpdates(results);
expect(updates[0].dataFormat).toBe('JSX');
});

it('handles missing optional metadata', () => {
const results: ExtractionResult[] = [
{
dataFormat: 'ICU',
source: 'Simple string',
metadata: {},
},
];

const updates = mapExtractionResultsToUpdates(results);

expect(updates).toHaveLength(1);
expect(updates[0].metadata).toEqual({});
expect(updates[0].metadata.id).toBeUndefined();
expect(updates[0].metadata.context).toBeUndefined();
expect(updates[0].metadata.maxChars).toBeUndefined();
});

it('preserves filePaths array', () => {
const results: ExtractionResult[] = [
{
dataFormat: 'ICU',
source: 'Multi-file string',
metadata: {
filePaths: ['routes/index.py', 'routes/auth.py'],
},
},
];

const updates = mapExtractionResultsToUpdates(results);
expect(updates[0].metadata.filePaths).toEqual([
'routes/index.py',
'routes/auth.py',
]);
});

it('maps multiple results', () => {
const results: ExtractionResult[] = [
{
dataFormat: 'ICU',
source: 'Hello',
metadata: { id: 'hello' },
},
{
dataFormat: 'ICU',
source: 'Goodbye',
metadata: { id: 'goodbye', context: 'farewell' },
},
];

const updates = mapExtractionResultsToUpdates(results);
expect(updates).toHaveLength(2);
expect(updates[0].source).toBe('Hello');
expect(updates[1].source).toBe('Goodbye');
expect(updates[1].metadata.context).toBe('farewell');
});
});
125 changes: 125 additions & 0 deletions packages/cli/src/extraction/__tests__/postProcess.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import { describe, it, expect } from 'vitest';
import {
calculateHashes,
dedupeUpdates,
linkStaticUpdates,
} from '../postProcess.js';
import type { Updates } from '../../types/index.js';

describe('calculateHashes', () => {
it('generates consistent hashes for same input', async () => {
const updates1: Updates = [
{ dataFormat: 'ICU', source: 'hello', metadata: {} },
];
const updates2: Updates = [
{ dataFormat: 'ICU', source: 'hello', metadata: {} },
];

await calculateHashes(updates1);
await calculateHashes(updates2);

expect(updates1[0].metadata.hash).toBeDefined();
expect(updates1[0].metadata.hash).toBe(updates2[0].metadata.hash);
});

it('generates different hashes for different sources', async () => {
const updates: Updates = [
{ dataFormat: 'ICU', source: 'hello', metadata: {} },
{ dataFormat: 'ICU', source: 'world', metadata: {} },
];

await calculateHashes(updates);

expect(updates[0].metadata.hash).not.toBe(updates[1].metadata.hash);
});
});

describe('dedupeUpdates', () => {
it('removes duplicates with same hash, merges filePaths', () => {
const updates: Updates = [
{
dataFormat: 'ICU',
source: 'hello',
metadata: { hash: 'h1', filePaths: ['pathA'] },
},
{
dataFormat: 'ICU',
source: 'hello',
metadata: { hash: 'h1', filePaths: ['pathB'] },
},
];

dedupeUpdates(updates);

expect(updates).toHaveLength(1);
expect(updates[0].metadata.filePaths).toEqual(['pathA', 'pathB']);
});

it('keeps distinct entries with different hashes', () => {
const updates: Updates = [
{
dataFormat: 'ICU',
source: 'hello',
metadata: { hash: 'h1', filePaths: ['pathA'] },
},
{
dataFormat: 'ICU',
source: 'world',
metadata: { hash: 'h2', filePaths: ['pathB'] },
},
];

dedupeUpdates(updates);

expect(updates).toHaveLength(2);
});

it('handles entries without hashes', () => {
const updates: Updates = [
{ dataFormat: 'ICU', source: 'no-hash', metadata: {} },
{
dataFormat: 'ICU',
source: 'has-hash',
metadata: { hash: 'h1', filePaths: ['pathA'] },
},
];

dedupeUpdates(updates);

expect(updates).toHaveLength(2);
});
});

describe('linkStaticUpdates', () => {
it('groups entries by temporary staticId and assigns shared hash', () => {
const updates: Updates = [
{
dataFormat: 'ICU',
source: 'variant-a',
metadata: { hash: 'ha', staticId: 'temp-static' },
},
{
dataFormat: 'ICU',
source: 'variant-b',
metadata: { hash: 'hb', staticId: 'temp-static' },
},
];

linkStaticUpdates(updates);

// Both should now share the same staticId (derived from their hashes)
expect(updates[0].metadata.staticId).toBe(updates[1].metadata.staticId);
// The staticId should have been replaced (no longer the temporary value)
expect(updates[0].metadata.staticId).not.toBe('temp-static');
});

it('does not modify entries without staticId', () => {
const updates: Updates = [
{ dataFormat: 'ICU', source: 'no-static', metadata: { hash: 'h1' } },
];

linkStaticUpdates(updates);

expect(updates[0].metadata.staticId).toBeUndefined();
});
});
7 changes: 7 additions & 0 deletions packages/cli/src/extraction/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export type { ExtractionResult, ExtractionMetadata } from './types.js';
export { mapExtractionResultsToUpdates } from './mapToUpdates.js';
export {
calculateHashes,
dedupeUpdates,
linkStaticUpdates,
} from './postProcess.js';
25 changes: 25 additions & 0 deletions packages/cli/src/extraction/mapToUpdates.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import type { ExtractionResult } from '@generaltranslation/python-extractor';
import type { Updates } from '../types/index.js';

/**
* Maps ExtractionResult[] to Updates[] format used by the CLI pipeline
*/
export function mapExtractionResultsToUpdates(
results: ExtractionResult[]
): Updates {
return results.map((result) => ({
dataFormat: result.dataFormat,
source: result.source,
metadata: {
...(result.metadata.id && { id: result.metadata.id }),
...(result.metadata.context && { context: result.metadata.context }),
...(result.metadata.maxChars != null && {
maxChars: result.metadata.maxChars,
}),
...(result.metadata.filePaths && {
filePaths: result.metadata.filePaths,
}),
...(result.metadata.staticId && { staticId: result.metadata.staticId }),
},
}));
}
Loading
Loading