diff --git a/topology/python_manifest.go b/topology/python_manifest.go new file mode 100644 index 0000000..84b8950 --- /dev/null +++ b/topology/python_manifest.go @@ -0,0 +1,247 @@ +package topology + +import ( + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "unicode" + + toml "github.com/pelletier/go-toml/v2" +) + +type pythonManifest struct { + Path string + Root string + Name string + NormalizedName string + Dependencies []pythonDependency + WorkspaceMembers []string + WorkspaceExclude []string + UVSources map[string][]pythonLocalSource + PoetrySources map[string][]pythonLocalSource +} + +type pythonDependency struct { + Name string + Conditional bool +} + +type pythonLocalSource struct { + Path string + Workspace bool + Conditional bool +} + +type pythonProjectDocument struct { + Project struct { + Name string `toml:"name"` + Dependencies []string `toml:"dependencies"` + OptionalDependencies map[string][]string `toml:"optional-dependencies"` + Dynamic []string `toml:"dynamic"` + } `toml:"project"` + Tool struct { + UV struct { + Workspace struct { + Members []string `toml:"members"` + Exclude []string `toml:"exclude"` + } `toml:"workspace"` + Sources map[string]any `toml:"sources"` + } `toml:"uv"` + Poetry struct { + Name string `toml:"name"` + Dependencies map[string]any `toml:"dependencies"` + } `toml:"poetry"` + } `toml:"tool"` +} + +func parsePythonManifest(root, path string) (pythonManifest, []Issue, error) { + data, err := os.ReadFile(filepath.Join(root, path)) + if err != nil { + return pythonManifest{}, nil, err + } + var document pythonProjectDocument + if err := toml.Unmarshal(data, &document); err != nil { + return pythonManifest{}, nil, err + } + + manifest := pythonManifest{ + Path: path, + Root: filepath.Dir(path), + WorkspaceMembers: append([]string(nil), document.Tool.UV.Workspace.Members...), + WorkspaceExclude: append([]string(nil), document.Tool.UV.Workspace.Exclude...), + UVSources: make(map[string][]pythonLocalSource), + PoetrySources: make(map[string][]pythonLocalSource), + } + var issues []Issue + projectName := strings.TrimSpace(document.Project.Name) + poetryName := strings.TrimSpace(document.Tool.Poetry.Name) + switch { + case projectName != "": + manifest.Name = projectName + if poetryName != "" && normalizePythonProjectName(projectName) != normalizePythonProjectName(poetryName) { + issues = append(issues, pythonIssue("conflicting-project-name", + fmt.Sprintf("%s declares project name %q and Poetry name %q", path, projectName, poetryName))) + } + case poetryName != "": + manifest.Name = poetryName + } + manifest.NormalizedName = normalizePythonProjectName(manifest.Name) + + for _, dynamic := range document.Project.Dynamic { + if strings.EqualFold(strings.TrimSpace(dynamic), "dependencies") { + issues = append(issues, pythonIssue("dynamic-dependencies", + fmt.Sprintf("%s computes project dependencies dynamically", path))) + break + } + } + for _, dependency := range document.Project.Dependencies { + parsed, ok := parsePythonDependency(dependency) + if !ok { + issues = append(issues, pythonIssue("unsupported-dependency", + fmt.Sprintf("%s has an unsupported dependency declaration %q", path, dependency))) + continue + } + manifest.Dependencies = append(manifest.Dependencies, parsed) + } + groups := make([]string, 0, len(document.Project.OptionalDependencies)) + for group := range document.Project.OptionalDependencies { + groups = append(groups, group) + } + sort.Strings(groups) + for _, group := range groups { + for _, dependency := range document.Project.OptionalDependencies[group] { + parsed, ok := parsePythonDependency(dependency) + if !ok { + issues = append(issues, pythonIssue("unsupported-dependency", + fmt.Sprintf("%s has an unsupported optional dependency declaration %q", path, dependency))) + continue + } + parsed.Conditional = true + manifest.Dependencies = append(manifest.Dependencies, parsed) + } + } + for name, value := range document.Tool.UV.Sources { + normalized := normalizePythonProjectName(name) + sources, ok := decodePythonLocalSources(value) + if !ok { + issues = append(issues, pythonIssue("unsupported-local-source", + fmt.Sprintf("%s has an unsupported uv source for %q", path, name))) + continue + } + manifest.UVSources[normalized] = sources + } + for name, value := range document.Tool.Poetry.Dependencies { + if strings.EqualFold(name, "python") { + continue + } + sources, ok := decodePythonLocalSources(value) + if !ok { + continue + } + hasLocal := false + for _, source := range sources { + if source.Path != "" { + hasLocal = true + break + } + } + if hasLocal { + manifest.PoetrySources[normalizePythonProjectName(name)] = sources + } + } + return manifest, issues, nil +} + +func decodePythonLocalSources(value any) ([]pythonLocalSource, bool) { + switch typed := value.(type) { + case map[string]any: + source, ok := decodePythonLocalSource(typed) + if !ok { + return nil, false + } + return []pythonLocalSource{source}, true + case []any: + sources := make([]pythonLocalSource, 0, len(typed)) + for _, item := range typed { + table, ok := item.(map[string]any) + if !ok { + return nil, false + } + source, ok := decodePythonLocalSource(table) + if !ok { + return nil, false + } + source.Conditional = true + sources = append(sources, source) + } + return sources, len(sources) > 0 + default: + return nil, false + } +} + +func decodePythonLocalSource(table map[string]any) (pythonLocalSource, bool) { + source := pythonLocalSource{} + if path, ok := table["path"].(string); ok { + source.Path = strings.TrimSpace(path) + } + if workspace, ok := table["workspace"].(bool); ok { + source.Workspace = workspace + } + for _, key := range []string{"marker", "markers"} { + if marker, ok := table[key].(string); ok && strings.TrimSpace(marker) != "" { + source.Conditional = true + } + } + return source, source.Path != "" || source.Workspace +} + +func parsePythonDependency(dependency string) (pythonDependency, bool) { + dependency = strings.TrimSpace(dependency) + conditional := false + if marker := strings.IndexByte(dependency, ';'); marker >= 0 { + if strings.TrimSpace(dependency[marker+1:]) == "" { + return pythonDependency{}, false + } + dependency = strings.TrimSpace(dependency[:marker]) + conditional = true + } + end := 0 + for end < len(dependency) { + r := rune(dependency[end]) + if !(unicode.IsLetter(r) || unicode.IsDigit(r) || r == '-' || r == '_' || r == '.') { + break + } + end++ + } + if end == 0 { + return pythonDependency{}, false + } + name := normalizePythonProjectName(dependency[:end]) + return pythonDependency{Name: name, Conditional: conditional}, name != "" +} + +func normalizePythonProjectName(name string) string { + name = strings.TrimSpace(strings.ToLower(name)) + var result strings.Builder + separator := false + for _, r := range name { + switch { + case unicode.IsLetter(r) || unicode.IsDigit(r): + if separator && result.Len() > 0 { + result.WriteByte('-') + } + separator = false + result.WriteRune(r) + case r == '-' || r == '_' || r == '.': + separator = true + } + } + return strings.Trim(result.String(), "-") +} + +func pythonIssue(code, message string) Issue { + return Issue{Provider: "python", Code: code, Message: message} +} diff --git a/topology/python_provider.go b/topology/python_provider.go new file mode 100644 index 0000000..44b9737 --- /dev/null +++ b/topology/python_provider.go @@ -0,0 +1,373 @@ +package topology + +import ( + "context" + "fmt" + "os" + "path" + "path/filepath" + "sort" + "strings" + + "codemap/scanner" +) + +type pythonProvider struct{} + +type pythonWorkspace struct { + RootManifest string + Root string + Members map[string]bool +} + +func init() { + RegisterProvider(pythonProvider{}) +} + +func (pythonProvider) Name() string { return "python" } +func (pythonProvider) Version() string { return "1" } +func (pythonProvider) Languages() []string { return []string{"python", "py"} } +func (pythonProvider) Manifests() ManifestSelector { + return ManifestSelector{Names: []string{"pyproject.toml"}} +} + +func (pythonProvider) Build(ctx context.Context, inventory Inventory) (Fragment, error) { + fragment := Fragment{ + Provider: "python", + Members: make(map[ID][]string), + Coverage: Coverage{Status: CoverageComplete}, + } + manifests := make([]pythonManifest, 0, len(inventory.Manifests)) + for _, manifestPath := range inventory.Manifests { + if err := ctx.Err(); err != nil { + return Fragment{}, err + } + if filepath.Base(manifestPath) != "pyproject.toml" { + continue + } + parsed, issues, err := parsePythonManifest(inventory.Root, manifestPath) + fragment.Coverage.Issues = append(fragment.Coverage.Issues, issues...) + if err != nil { + fragment.Coverage.Issues = append(fragment.Coverage.Issues, pythonIssue( + "malformed-manifest", + fmt.Sprintf("%s could not be parsed: %v", manifestPath, err), + )) + continue + } + manifests = append(manifests, parsed) + } + + nodeByManifest := make(map[string]Node) + for _, manifest := range manifests { + if manifest.NormalizedName == "" { + continue + } + node := pythonNode(manifest, inventory.Files) + nodeByManifest[manifest.Path] = node + fragment.Nodes = append(fragment.Nodes, node) + } + workspaces, workspaceIssues := pythonWorkspaces(manifests, nodeByManifest) + fragment.Coverage.Issues = append(fragment.Coverage.Issues, workspaceIssues...) + + for _, manifest := range manifests { + if err := ctx.Err(); err != nil { + return Fragment{}, err + } + from, ok := nodeByManifest[manifest.Path] + if !ok { + continue + } + for _, dependency := range manifest.Dependencies { + for _, source := range manifest.UVSources[dependency.Name] { + targets, issues := resolvePythonSource(inventory.Root, manifest, dependency.Name, source, workspaces, nodeByManifest) + fragment.Coverage.Issues = append(fragment.Coverage.Issues, issues...) + fragment.Edges = appendPythonEdges( + fragment.Edges, + from.ID, + targets, + manifest.Path, + source.Conditional || dependency.Conditional, + ) + } + } + names := make([]string, 0, len(manifest.PoetrySources)) + for name := range manifest.PoetrySources { + names = append(names, name) + } + sort.Strings(names) + for _, dependency := range names { + for _, source := range manifest.PoetrySources[dependency] { + targets, issues := resolvePythonSource(inventory.Root, manifest, dependency, source, workspaces, nodeByManifest) + fragment.Coverage.Issues = append(fragment.Coverage.Issues, issues...) + fragment.Edges = appendPythonEdges(fragment.Edges, from.ID, targets, manifest.Path, source.Conditional) + } + } + } + + fragment.Members = pythonMembers(inventory.Files, fragment.Nodes) + if len(fragment.Coverage.Issues) > 0 { + fragment.Coverage.Status = CoveragePartial + } + return fragment, nil +} + +func pythonNode(manifest pythonManifest, files []scanner.FileInfo) Node { + root := filepath.Clean(manifest.Root) + node := Node{ + ID: ID("python:" + filepath.ToSlash(manifest.Path) + ":" + manifest.NormalizedName), + Kind: NodeKind("python-project"), + Name: manifest.Name, + Manifest: manifest.Path, + Root: root, + Provider: "python", + } + packageDir := strings.ReplaceAll(manifest.NormalizedName, "-", "_") + for _, candidate := range []string{filepath.Join(root, "src"), filepath.Join(root, packageDir)} { + if pythonFilesUnder(files, candidate) { + node.SourceRoots = append(node.SourceRoots, candidate) + } + } + for _, candidate := range []string{filepath.Join(root, "tests"), filepath.Join(root, "test")} { + if pythonFilesUnder(files, candidate) { + node.TestSourceRoots = append(node.TestSourceRoots, candidate) + } + } + node.SourceRoots = uniqueSortedStrings(node.SourceRoots) + node.TestSourceRoots = uniqueSortedStrings(node.TestSourceRoots) + return node +} + +func pythonFilesUnder(files []scanner.FileInfo, root string) bool { + for _, file := range files { + if repoPathContains(root, file.Path) { + return true + } + } + return false +} + +func pythonMembers(files []scanner.FileInfo, nodes []Node) map[ID][]string { + members := make(map[ID][]string) + sortedNodes := append([]Node(nil), nodes...) + sort.Slice(sortedNodes, func(i, j int) bool { + leftDepth := repoPathDepth(sortedNodes[i].Root) + rightDepth := repoPathDepth(sortedNodes[j].Root) + if leftDepth != rightDepth { + return leftDepth > rightDepth + } + return sortedNodes[i].ID < sortedNodes[j].ID + }) + for _, file := range files { + var owner *Node + for i := range sortedNodes { + if repoPathContains(sortedNodes[i].Root, file.Path) { + owner = &sortedNodes[i] + break + } + } + if owner == nil { + continue + } + roots := append(append([]string(nil), owner.SourceRoots...), owner.TestSourceRoots...) + for _, sourceRoot := range roots { + if repoPathContains(sourceRoot, file.Path) { + members[owner.ID] = append(members[owner.ID], filepath.Clean(file.Path)) + break + } + } + } + return members +} + +func pythonWorkspaces(manifests []pythonManifest, nodes map[string]Node) ([]pythonWorkspace, []Issue) { + var workspaces []pythonWorkspace + var issues []Issue + for _, manifest := range manifests { + if len(manifest.WorkspaceMembers) == 0 { + continue + } + workspace := pythonWorkspace{ + RootManifest: manifest.Path, + Root: manifest.Root, + Members: make(map[string]bool), + } + if _, ok := nodes[manifest.Path]; ok { + workspace.Members[manifest.Path] = true + } + for _, candidate := range manifests { + relative, err := filepath.Rel(manifest.Root, candidate.Root) + if err != nil || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) { + continue + } + relative = filepath.ToSlash(relative) + included, valid := matchesAnyPythonPattern(manifest.WorkspaceMembers, relative) + if !valid { + issues = append(issues, pythonIssue("invalid-workspace-pattern", + fmt.Sprintf("%s has an invalid workspace member pattern", manifest.Path))) + break + } + excluded, valid := matchesAnyPythonPattern(manifest.WorkspaceExclude, relative) + if !valid { + issues = append(issues, pythonIssue("invalid-workspace-pattern", + fmt.Sprintf("%s has an invalid workspace exclude pattern", manifest.Path))) + break + } + if included && !excluded { + workspace.Members[candidate.Path] = true + } + } + workspaces = append(workspaces, workspace) + } + sort.Slice(workspaces, func(i, j int) bool { + if repoPathDepth(workspaces[i].Root) != repoPathDepth(workspaces[j].Root) { + return repoPathDepth(workspaces[i].Root) > repoPathDepth(workspaces[j].Root) + } + return workspaces[i].RootManifest < workspaces[j].RootManifest + }) + return workspaces, issues +} + +func matchesAnyPythonPattern(patterns []string, candidate string) (bool, bool) { + for _, patternValue := range patterns { + patternValue = strings.TrimPrefix(filepath.ToSlash(filepath.Clean(patternValue)), "./") + matched, err := path.Match(patternValue, candidate) + if err != nil { + return false, false + } + if matched { + return true, true + } + } + return false, true +} + +func resolvePythonSource( + root string, + manifest pythonManifest, + dependency string, + source pythonLocalSource, + workspaces []pythonWorkspace, + nodes map[string]Node, +) ([]ID, []Issue) { + if source.Workspace { + for _, workspace := range workspaces { + if manifest.Path != workspace.RootManifest && !workspace.Members[manifest.Path] { + continue + } + var candidates []ID + for manifestPath := range workspace.Members { + node, ok := nodes[manifestPath] + if ok && normalizePythonProjectName(node.Name) == dependency { + candidates = append(candidates, node.ID) + } + } + candidates = uniqueSortedIDs(candidates) + switch len(candidates) { + case 1: + return candidates, nil + case 0: + return nil, []Issue{pythonIssue("missing-local-source", + fmt.Sprintf("%s cannot resolve workspace dependency %q", manifest.Path, dependency))} + default: + issue := pythonIssue("ambiguous-local-dependency", + fmt.Sprintf("%s has multiple workspace projects named %q", manifest.Path, dependency)) + issue.Candidates = candidates + return nil, []Issue{issue} + } + } + return nil, []Issue{pythonIssue("missing-local-source", + fmt.Sprintf("%s is not contained in a uv workspace for dependency %q", manifest.Path, dependency))} + } + if source.Path == "" { + return nil, nil + } + targetManifest, issue := resolvePythonPath(root, manifest, source.Path) + if issue != nil { + return nil, []Issue{*issue} + } + target, ok := nodes[targetManifest] + if !ok { + return nil, []Issue{pythonIssue("missing-local-source", + fmt.Sprintf("%s local source %q is not a configured Python project", manifest.Path, source.Path))} + } + if normalizePythonProjectName(target.Name) != dependency { + return nil, []Issue{pythonIssue("local-source-name-mismatch", + fmt.Sprintf("%s dependency %q points to project %q", manifest.Path, dependency, target.Name))} + } + return []ID{target.ID}, nil +} + +func resolvePythonPath(root string, manifest pythonManifest, sourcePath string) (string, *Issue) { + if filepath.IsAbs(sourcePath) { + issue := pythonIssue("invalid-local-source", + fmt.Sprintf("%s local source %q must be repository-relative", manifest.Path, sourcePath)) + return "", &issue + } + joined := filepath.Join(manifest.Root, filepath.FromSlash(sourcePath)) + relative, err := normalizeRepoPath(root, joined) + if err != nil { + issue := pythonIssue("invalid-local-source", + fmt.Sprintf("%s local source %q: %v", manifest.Path, sourcePath, err)) + return "", &issue + } + absolute := filepath.Join(root, relative) + info, err := os.Stat(absolute) + if err != nil { + issue := pythonIssue("missing-local-source", + fmt.Sprintf("%s local source %q does not exist", manifest.Path, sourcePath)) + return "", &issue + } + realRoot, rootErr := filepath.EvalSymlinks(root) + realTarget, targetErr := filepath.EvalSymlinks(absolute) + if rootErr != nil || targetErr != nil { + issue := pythonIssue("invalid-local-source", + fmt.Sprintf("%s local source %q could not be resolved safely", manifest.Path, sourcePath)) + return "", &issue + } + realRelative, err := filepath.Rel(realRoot, realTarget) + if err != nil || realRelative == ".." || strings.HasPrefix(realRelative, ".."+string(filepath.Separator)) { + issue := pythonIssue("invalid-local-source", + fmt.Sprintf("%s local source %q escapes the repository", manifest.Path, sourcePath)) + return "", &issue + } + if info.IsDir() { + relative = filepath.Join(relative, "pyproject.toml") + } else if filepath.Base(relative) != "pyproject.toml" { + issue := pythonIssue("invalid-local-source", + fmt.Sprintf("%s local source %q is not a Python project", manifest.Path, sourcePath)) + return "", &issue + } + return filepath.Clean(relative), nil +} + +func appendPythonEdges(edges []Edge, from ID, targets []ID, manifest string, conditional bool) []Edge { + for _, target := range targets { + edges = append(edges, Edge{ + From: from, + To: target, + Kind: EdgeDependency, + Scope: EdgeScope("runtime"), + Evidence: Evidence{Manifest: manifest}, + Conditional: conditional, + }) + } + return edges +} + +func repoPathContains(root, candidate string) bool { + root = filepath.Clean(root) + candidate = filepath.Clean(candidate) + if root == "." { + return candidate != ".." && !strings.HasPrefix(candidate, ".."+string(filepath.Separator)) + } + relative, err := filepath.Rel(root, candidate) + return err == nil && relative != ".." && !strings.HasPrefix(relative, ".."+string(filepath.Separator)) +} + +func repoPathDepth(root string) int { + root = filepath.Clean(root) + if root == "." { + return 0 + } + return len(strings.Split(filepath.ToSlash(root), "/")) +} diff --git a/topology/python_provider_test.go b/topology/python_provider_test.go new file mode 100644 index 0000000..f1052b1 --- /dev/null +++ b/topology/python_provider_test.go @@ -0,0 +1,200 @@ +package topology + +import ( + "context" + "path/filepath" + "reflect" + "testing" +) + +func TestPythonProviderBuildsModernWorkspaceTopology(t *testing.T) { + root := t.TempDir() + writeTopologyFixture(t, root, "pyproject.toml", ` +[project] +name = "app" +dependencies = ["lib>=1", "shared", "marker-lib; sys_platform == 'darwin'"] + +[project.optional-dependencies] +test = ["extra-lib"] + +[tool.uv.workspace] +members = ["packages/*", "libs/*"] + +[tool.uv.sources] +lib = { workspace = true } +marker-lib = { workspace = true } +extra-lib = { workspace = true } +shared = [ + { path = "libs/shared", marker = "sys_platform == 'darwin'" } +] +`) + writeTopologyFixture(t, root, "packages/lib/pyproject.toml", ` +[project] +name = "lib" +`) + writeTopologyFixture(t, root, "packages/marker/pyproject.toml", ` +[project] +name = "marker-lib" +`) + writeTopologyFixture(t, root, "packages/extra/pyproject.toml", ` +[project] +name = "extra-lib" +`) + writeTopologyFixture(t, root, "libs/shared/pyproject.toml", ` +[tool.poetry] +name = "shared" +`) + writeTopologyFixture(t, root, "src/app/__init__.py", "") + writeTopologyFixture(t, root, "packages/lib/src/lib/__init__.py", "") + writeTopologyFixture(t, root, "packages/lib/tests/test_lib.py", "") + writeTopologyFixture(t, root, "libs/shared/shared/__init__.py", "") + + graph, _, err := BuildGraphWithProviders(context.Background(), root, []Provider{pythonProvider{}}) + if err != nil { + t.Fatal(err) + } + + app := ID("python:pyproject.toml:app") + lib := ID("python:packages/lib/pyproject.toml:lib") + marker := ID("python:packages/marker/pyproject.toml:marker-lib") + extra := ID("python:packages/extra/pyproject.toml:extra-lib") + shared := ID("python:libs/shared/pyproject.toml:shared") + if got := sortedNodeIDs(graph.Nodes); !reflect.DeepEqual(got, []ID{shared, extra, lib, marker, app}) { + t.Fatalf("nodes = %#v", got) + } + if got := graph.Dependencies[app]; len(got) != 4 || + got[0].To != shared || !got[0].Conditional || + got[1].To != extra || !got[1].Conditional || + got[2].To != lib || got[2].Conditional || + got[3].To != marker || !got[3].Conditional { + t.Fatalf("app dependencies = %#v", got) + } + assertPythonOwner(t, graph, "src/app/__init__.py", app) + assertPythonOwner(t, graph, "packages/lib/src/lib/__init__.py", lib) + assertPythonOwner(t, graph, "packages/lib/tests/test_lib.py", lib) + assertPythonOwner(t, graph, "libs/shared/shared/__init__.py", shared) + if graph.Coverage.Status != CoverageComplete { + t.Fatalf("coverage = %#v", graph.Coverage) + } +} + +func TestPythonProviderResolvesPoetryPathsAndRejectsUnsafeOrAmbiguousSources(t *testing.T) { + root := t.TempDir() + writeTopologyFixture(t, root, ".gitignore", "ignored/\n") + writeTopologyFixture(t, root, "pyproject.toml", ` +[project] +name = "app" +dependencies = ["dupe-name", "escape", "missing", "absolute", "ignored"] + +[tool.poetry] +name = "different-app" + +[tool.uv.workspace] +members = ["packages/*"] + +[tool.uv.sources] +"dupe.name" = { workspace = true } +escape = { path = "../outside" } +missing = { path = "missing" } +absolute = { path = "/tmp/absolute" } +ignored = { path = "ignored" } +`) + writeTopologyFixture(t, root, "packages/a/pyproject.toml", "[project]\nname = \"dupe_name\"\n") + writeTopologyFixture(t, root, "packages/b/pyproject.toml", "[project]\nname = \"dupe-name\"\n") + writeTopologyFixture(t, root, "packages/poetry/pyproject.toml", ` +[tool.poetry] +name = "poetry-lib" + +[tool.poetry.dependencies] +python = "^3.12" +app = { path = "../..", markers = "python_version >= '3.12'" } +`) + writeTopologyFixture(t, root, "ignored/pyproject.toml", "[project]\nname = \"ignored\"\n") + writeTopologyFixture(t, root, "packages/poetry/poetry_lib/__init__.py", "") + + graph, _, err := BuildGraphWithProviders(context.Background(), root, []Provider{pythonProvider{}}) + if err != nil { + t.Fatal(err) + } + + app := ID("python:pyproject.toml:app") + poetry := ID("python:packages/poetry/pyproject.toml:poetry-lib") + if got := graph.Dependencies[app]; len(got) != 0 { + t.Fatalf("unsafe or ambiguous dependencies resolved: %#v", got) + } + if got := graph.Dependencies[poetry]; len(got) != 1 || got[0].To != app || !got[0].Conditional { + t.Fatalf("Poetry dependencies = %#v", got) + } + assertPythonOwner(t, graph, "packages/poetry/poetry_lib/__init__.py", poetry) + for _, code := range []string{ + "ambiguous-local-dependency", + "conflicting-project-name", + "invalid-local-source", + "missing-local-source", + } { + if !hasIssueCode(graph.Coverage.Issues, code) { + t.Fatalf("issues = %#v, want %q", graph.Coverage.Issues, code) + } + } + if graph.Coverage.Status != CoveragePartial { + t.Fatalf("coverage = %#v", graph.Coverage) + } +} + +func TestPythonProviderKeepsMalformedAndDynamicProjectsPartial(t *testing.T) { + root := t.TempDir() + writeTopologyFixture(t, root, "pyproject.toml", ` +[project] +name = "root-project" +dynamic = ["dependencies"] +`) + writeTopologyFixture(t, root, "broken/pyproject.toml", "[project\nname = ") + writeTopologyFixture(t, root, "src/root_project/__init__.py", "") + + graph, _, err := BuildGraphWithProviders(context.Background(), root, []Provider{pythonProvider{}}) + if err != nil { + t.Fatal(err) + } + rootID := ID("python:pyproject.toml:root-project") + if _, ok := graph.Nodes[rootID]; !ok { + t.Fatalf("valid project missing: %#v", graph.Nodes) + } + assertPythonOwner(t, graph, "src/root_project/__init__.py", rootID) + if !hasIssueCode(graph.Coverage.Issues, "dynamic-dependencies") || + !hasIssueCode(graph.Coverage.Issues, "malformed-manifest") { + t.Fatalf("coverage issues = %#v", graph.Coverage.Issues) + } +} + +func TestPythonProviderOnlyLanguageIncludesPythonFiles(t *testing.T) { + root := t.TempDir() + writeTopologyFixture(t, root, ".codemap/config.json", `{"only":["py"]}`) + writeTopologyFixture(t, root, "pyproject.toml", "[project]\nname = \"app\"\n") + writeTopologyFixture(t, root, "src/app/__init__.py", "") + + graph, _, err := BuildGraphWithProviders(context.Background(), root, []Provider{pythonProvider{}}) + if err != nil { + t.Fatal(err) + } + assertPythonOwner(t, graph, "src/app/__init__.py", ID("python:pyproject.toml:app")) +} + +func TestNormalizePythonProjectName(t *testing.T) { + for input, want := range map[string]string{ + "Foo.Bar_baz": "foo-bar-baz", + "plain": "plain", + " Mixed--_": "mixed", + } { + if got := normalizePythonProjectName(input); got != want { + t.Fatalf("normalizePythonProjectName(%q) = %q, want %q", input, got, want) + } + } +} + +func assertPythonOwner(t *testing.T, graph *Graph, path string, want ID) { + t.Helper() + path = filepath.Clean(path) + if got := graph.Owners[path]; !reflect.DeepEqual(got, []ID{want}) { + t.Fatalf("owners[%q] = %#v, want %#v", path, got, []ID{want}) + } +}