Skip to content

Commit d06ec8d

Browse files
fix(py#project): use PEP 503 distribution names so nx infers workspace edges (#846)
py#project assigns one name to both the nx project id and the Python distribution name (`${scope}.${normalizedName}`, e.g. `sojourner.agent`). The dot makes the distribution name break @nxlv/python's nx-graph dependency inference: it splits a dotted dependency on `.` (truncating it before lookup), and looks up uv.lock by the dotted pyproject name while uv writes the lockfile keyed by the PEP 503 hyphenated name. The result is that a Python workspace dependency (e.g. an agent on its agent_connection library) never becomes an edge in the nx project graph, so a change confined to that library does not invalidate the dependent's cache and a stale build/image can be produced. Emit a PEP 503 normalised distribution name (lower-cased, runs of `.`/`_`/`-` collapsed to a single `-`) while keeping the nx project id dotted. The @nxlv/python uv-project generator accepts a separate `packageName` distinct from `name`, so the two decouple cleanly: `name` stays the dotted nx id, `packageName` becomes the hyphenated distribution name written into pyproject `[project].name` and the root `[tool.uv.sources]` key. Make addWorkspaceDependencyToPyProject the single, hard-to-misuse entry point for wiring a Python workspace dependency: it takes the dependent and dependency projects (not a name string) and derives the dependency's distribution name from that project's own pyproject.toml, so callers cannot pass the dotted nx id by mistake. All agent connection generators route through it. - add normalizeDistributionName to utils/names (+ unit tests) - py#project: derive distributionName, pass it as uvProjectGenerator packageName - addWorkspaceDependencyToPyProject takes (dependent, dependency) projects - agent-connection util: expose getPythonAgentConnectionProject - update agent connection generators, snapshot + assertions Co-authored-by: nx-plugin-for-aws <nx-plugin-for-aws@users.noreply.github.com>
1 parent 68615e6 commit d06ec8d

11 files changed

Lines changed: 257 additions & 37 deletions

File tree

packages/nx-plugin/src/py/agent/__snapshots__/generator.spec.ts.snap

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -745,7 +745,7 @@ exports[`py#agent generator > should match snapshot for generated files > update
745745
name = "proj.test_project"
746746
version = "0.1.0"
747747
dependencies = [
748-
"proj.agent_connection",
748+
"proj-agent-connection",
749749
"aws-lambda-powertools==3.29.0",
750750
"aws-opentelemetry-distro==0.17.1",
751751
"bedrock-agentcore==1.13.0",
@@ -763,7 +763,7 @@ dev = [ "fastapi[standard]==0.136.3" ]
763763
[tool.uv]
764764
dev-dependencies = [ ]
765765
766-
[tool.uv.sources."proj.agent_connection"]
766+
[tool.uv.sources.proj-agent-connection]
767767
workspace = true
768768
"
769769
`;

packages/nx-plugin/src/py/agent/a2a-connection/generator.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ import {
3030
addPythonCoreClient,
3131
getPythonAgentConnectionProjectDir,
3232
getPythonAgentConnectionModuleName,
33-
getPythonAgentConnectionPackageName,
33+
getPythonAgentConnectionProject,
3434
addPythonReExport,
3535
} from '../../../utils/agent-connection/agent-connection';
3636
import {
@@ -92,7 +92,6 @@ export const pyAgentA2aConnectionGenerator = async (
9292

9393
const agentConnectionProjectDir = getPythonAgentConnectionProjectDir(tree);
9494
const agentConnectionModuleName = getPythonAgentConnectionModuleName(tree);
95-
const agentConnectionPackageName = getPythonAgentConnectionPackageName(tree);
9695

9796
// Python deps required by the A2A core client + shared auth helper.
9897
addDependenciesToPyProjectToml(tree, agentConnectionProjectDir, [
@@ -171,8 +170,8 @@ export const pyAgentA2aConnectionGenerator = async (
171170
// 4. Add workspace dependency from agent project to agent-connection project
172171
addWorkspaceDependencyToPyProject(
173172
tree,
174-
sourceProject.root,
175-
agentConnectionPackageName,
173+
sourceProject,
174+
getPythonAgentConnectionProject(tree),
176175
);
177176

178177
// 5. Set up serve-local target dependencies — chain onto the target agent

packages/nx-plugin/src/py/agent/generator.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ import { addAgentInfra } from '../../utils/agent-core-constructs/agent-core-cons
3434
import {
3535
ensurePythonAgentConnectionProject,
3636
getPythonAgentConnectionModuleName,
37-
getPythonAgentConnectionPackageName,
37+
getPythonAgentConnectionProject,
3838
} from '../../utils/agent-connection/agent-connection';
3939
import { addWorkspaceDependencyToPyProject } from '../../utils/py';
4040

@@ -106,11 +106,10 @@ export const pyAgentGenerator = async (
106106
// generator wires into this agent.
107107
await ensurePythonAgentConnectionProject(tree);
108108
const agentConnectionModuleName = getPythonAgentConnectionModuleName(tree);
109-
const agentConnectionPackageName = getPythonAgentConnectionPackageName(tree);
110109
addWorkspaceDependencyToPyProject(
111110
tree,
112-
project.root,
113-
agentConnectionPackageName,
111+
project,
112+
getPythonAgentConnectionProject(tree),
114113
);
115114

116115
const templateContext = {

packages/nx-plugin/src/py/agent/mcp-connection/generator.spec.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -248,8 +248,9 @@ dependencies = ["strands-agents"]
248248
'utf-8',
249249
)!;
250250

251-
// Check workspace dependency was added
252-
expect(pyprojectContent).toContain('proj.agent_connection');
251+
// Check workspace dependency was added, keyed by the PEP 503 distribution
252+
// name (hyphenated, not the dotted Nx id) so @nxlv/python infers the edge.
253+
expect(pyprojectContent).toContain('proj-agent-connection');
253254
});
254255

255256
it('should update serve-local target with MCP dependency', async () => {

packages/nx-plugin/src/py/agent/mcp-connection/generator.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ import {
3030
addPythonCoreClient,
3131
getPythonAgentConnectionProjectDir,
3232
getPythonAgentConnectionModuleName,
33-
getPythonAgentConnectionPackageName,
33+
getPythonAgentConnectionProject,
3434
addPythonReExport,
3535
} from '../../../utils/agent-connection/agent-connection';
3636
import {
@@ -86,7 +86,6 @@ export const pyAgentMcpConnectionGenerator = async (
8686

8787
const agentConnectionProjectDir = getPythonAgentConnectionProjectDir(tree);
8888
const agentConnectionModuleName = getPythonAgentConnectionModuleName(tree);
89-
const agentConnectionPackageName = getPythonAgentConnectionPackageName(tree);
9089

9190
// Python deps required by the MCP core client + shared auth helper.
9291
addDependenciesToPyProjectToml(tree, agentConnectionProjectDir, [
@@ -163,8 +162,8 @@ export const pyAgentMcpConnectionGenerator = async (
163162
// 4. Add workspace dependency from agent project to agent-connection project
164163
addWorkspaceDependencyToPyProject(
165164
tree,
166-
sourceProject.root,
167-
agentConnectionPackageName,
165+
sourceProject,
166+
getPythonAgentConnectionProject(tree),
168167
);
169168

170169
// 5. Set up serve-local target dependencies

packages/nx-plugin/src/py/project/generator.ts

Lines changed: 28 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ import {
2323
} from '../../utils/nxlv-python';
2424
import { withVersions } from '../../utils/versions';
2525
import { getNpmScope } from '../../utils/npm-scope';
26-
import { toSnakeCase } from '../../utils/names';
26+
import { normalizeDistributionName, toSnakeCase } from '../../utils/names';
2727
import { sortObjectKeys } from '../../utils/object';
2828
import { updateGitIgnore } from '../../utils/git';
2929
import {
@@ -41,9 +41,22 @@ export const PY_PROJECT_GENERATOR_INFO: NxGeneratorInfo =
4141

4242
export interface PyProjectDetails {
4343
/**
44-
* Full package name including scope (eg foo.bar)
44+
* Fully qualified Nx project id including scope, in dot notation (eg foo.bar).
45+
* This is the identifier Nx uses (readProjectConfiguration, the project
46+
* graph, target references) and is intentionally kept dotted.
4547
*/
4648
readonly fullyQualifiedName: string;
49+
/**
50+
* PEP 503 normalised Python distribution name (eg foo-bar). This is the name
51+
* written to the project's `[project].name`, the root `[tool.uv.sources]`
52+
* key, and any inter-project dependency string. It is hyphenated (not dotted)
53+
* so `uv` and `@nxlv/python` can match it: uv writes `uv.lock` keyed by the
54+
* PEP 503 hyphenated name, and `@nxlv/python`'s dependency inference splits a
55+
* dotted name on `.` and so would otherwise drop the edge entirely. Keeping
56+
* the distribution name decoupled from `fullyQualifiedName` is what lets the
57+
* workspace dependency edge appear in the Nx project graph.
58+
*/
59+
readonly distributionName: string;
4760
/**
4861
* Directory of the library relative to the root
4962
*/
@@ -72,12 +85,15 @@ export const getPyProjectDetails = (
7285
schema.moduleName ?? `${scope}_${normalizedName}`,
7386
);
7487
const fullyQualifiedName = `${scope}.${normalizedName}`;
88+
// The Python distribution name is the PEP 503 normalised form of the
89+
// fully qualified name: hyphenated, never dotted. See PyProjectDetails.
90+
const distributionName = normalizeDistributionName(fullyQualifiedName);
7591
// NB: interactive nx generator cli can pass empty string
7692
const dir = joinPathFragments(
7793
schema.directory || '.',
7894
schema.subDirectory || normalizedName,
7995
);
80-
return { dir, fullyQualifiedName, normalizedModuleName };
96+
return { dir, fullyQualifiedName, distributionName, normalizedModuleName };
8197
};
8298

8399
/**
@@ -87,10 +103,8 @@ export const pyProjectGenerator = async (
87103
tree: Tree,
88104
schema: PyProjectGeneratorSchema,
89105
): Promise<GeneratorCallback> => {
90-
const { dir, normalizedModuleName, fullyQualifiedName } = getPyProjectDetails(
91-
tree,
92-
schema,
93-
);
106+
const { dir, normalizedModuleName, fullyQualifiedName, distributionName } =
107+
getPyProjectDetails(tree, schema);
94108

95109
const pythonPlugin = withVersions(['@nxlv/python']);
96110
addDependenciesToPackageJson(tree, {}, pythonPlugin);
@@ -132,7 +146,14 @@ export const pyProjectGenerator = async (
132146
}
133147

134148
await uvProjectGenerator(tree, {
149+
// The Nx project id (and `uv.workspace.members` etc.) keys off `name`, so
150+
// keep it dotted. `packageName` is the separate PEP 503 distribution name
151+
// written to `[project].name` and the root `[tool.uv.sources]` key; it is
152+
// hyphenated so `@nxlv/python` infers the workspace dependency edge (it
153+
// splits a dotted name on `.` and would otherwise drop it). This split is
154+
// the whole fix (see PyProjectDetails.distributionName).
135155
name: fullyQualifiedName,
156+
packageName: distributionName,
136157
publishable: false,
137158
buildLockedVersions: true,
138159
buildBundleLocalDependencies: true,

packages/nx-plugin/src/utils/agent-connection/agent-connection.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -53,14 +53,20 @@ export function getPythonAgentConnectionModuleName(tree: Tree): string {
5353
}
5454

5555
/**
56-
* Get the fully qualified Python package name of the shared agent-connection project.
56+
* Get the shared agent-connection project as `{ name, root }`, suitable for
57+
* passing to {@link addWorkspaceDependencyToPyProject} (which derives the
58+
* dependency's PEP 503 distribution name from the project itself, so callers
59+
* never construct the name).
5760
*/
58-
export function getPythonAgentConnectionPackageName(tree: Tree): string {
59-
const { fullyQualifiedName } = getPyProjectDetails(tree, {
61+
export function getPythonAgentConnectionProject(tree: Tree): {
62+
name: string;
63+
root: string;
64+
} {
65+
const { fullyQualifiedName, dir } = getPyProjectDetails(tree, {
6066
name: PY_AGENT_CONNECTION_NAME,
6167
directory: joinPathFragments(PACKAGES_DIR, COMMON_DIR),
6268
});
63-
return fullyQualifiedName;
69+
return { name: fullyQualifiedName, root: dir };
6470
}
6571

6672
/**

packages/nx-plugin/src/utils/names.spec.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
toSnakeCase,
1010
toDotNotation,
1111
kebabCase,
12+
normalizeDistributionName,
1213
snakeCase,
1314
} from './names';
1415

@@ -593,6 +594,45 @@ describe('names utils', () => {
593594
});
594595
});
595596

597+
describe('normalizeDistributionName', () => {
598+
it('should hyphenate dotted scope-qualified names', () => {
599+
// The crux: a dotted nx id with an underscored project segment becomes a
600+
// fully hyphenated PEP 503 distribution name.
601+
expect(normalizeDistributionName('sojourner.agent_connection')).toBe(
602+
'sojourner-agent-connection',
603+
);
604+
expect(normalizeDistributionName('sojourner.agent')).toBe(
605+
'sojourner-agent',
606+
);
607+
expect(normalizeDistributionName('proj.test_project')).toBe(
608+
'proj-test-project',
609+
);
610+
});
611+
612+
it('should lower-case the name', () => {
613+
expect(normalizeDistributionName('MyScope.MyLib')).toBe('myscope-mylib');
614+
expect(normalizeDistributionName('Foo_Bar')).toBe('foo-bar');
615+
});
616+
617+
it('should collapse runs of ., _ and - to a single hyphen', () => {
618+
expect(normalizeDistributionName('a..b__c--d')).toBe('a-b-c-d');
619+
expect(normalizeDistributionName('a._-b')).toBe('a-b');
620+
});
621+
622+
it('should leave an already-normalised name unchanged (idempotent)', () => {
623+
expect(normalizeDistributionName('sojourner-agent-connection')).toBe(
624+
'sojourner-agent-connection',
625+
);
626+
const once = normalizeDistributionName('Some.Mixed_Name');
627+
expect(normalizeDistributionName(once)).toBe(once);
628+
});
629+
630+
it('should not split camelCase humps (this is not kebab-case)', () => {
631+
// PEP 503 only touches ., _ and -; camelCase boundaries are preserved.
632+
expect(normalizeDistributionName('myScope.myLib')).toBe('myscope-mylib');
633+
});
634+
});
635+
596636
describe('cross-function consistency', () => {
597637
describe('snakeCase and kebabCase should produce consistent word boundaries', () => {
598638
const testCases = [

packages/nx-plugin/src/utils/names.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,24 @@ export const kebabCase = (str: string): string => {
5959
);
6060
};
6161

62+
/**
63+
* Normalise a string to a PEP 503 distribution name.
64+
*
65+
* PEP 503 (https://peps.python.org/pep-0503/#normalized-names) defines the
66+
* canonical form of a Python project distribution name: lower-cased, with any
67+
* run of `.`, `_` or `-` collapsed to a single `-`. This is the name uv writes
68+
* into `uv.lock` and the form `@nxlv/python` expects when inferring workspace
69+
* dependency edges from `[project].dependencies` / `[tool.uv.sources]`.
70+
*
71+
* Importantly this is NOT kebab-case: it does not split on camelCase humps,
72+
* spaces or other punctuation, so a dotted nx id like `scope.my_lib` becomes
73+
* `scope-my-lib` (and only those three separators are touched).
74+
*
75+
* eg. `sojourner.agent_connection` -> `sojourner-agent-connection`
76+
*/
77+
export const normalizeDistributionName = (str: string): string =>
78+
str.toLowerCase().replace(/[._-]+/g, '-');
79+
6280
// Convert a string to a dot notation string (eg. lambda_handler/my_handler.py -> lambda_handler.my_handler)
6381
export const toDotNotation = (str: string): string =>
6482
str

0 commit comments

Comments
 (0)