Skip to content

Commit a0739fd

Browse files
committed
Add support for mapped repository names in use_repo_add
Implement support for three types of repository specifications: - Simple names: foo -> use_repo(ext, "foo") - Mapped names with valid identifiers: bar=baz -> use_repo(ext, bar = "baz") - Invalid identifiers use dict unpacking: foo.2=foo -> use_repo(ext, **{"foo.2": "foo"}) This follows the Bazel documentation recommendations for handling repository names that are not valid Python identifiers by using dictionary unpacking syntax. Changes: - Modified AddRepoUsages() to parse and handle mapped repo names - Added parseRepoMapping() helper to split "key=value" syntax - Added isValidIdentifier() to validate Python identifier syntax - Added addOrExtendDictUnpack() to handle dict unpacking - Added comprehensive test coverage for all formats - Updated README documentation with usage examples
1 parent 6e81bc3 commit a0739fd

3 files changed

Lines changed: 332 additions & 12 deletions

File tree

buildozer/README.md

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -172,7 +172,11 @@ The following commands only apply to `MODULE.bazel` files (e.g. the target
172172
* `use_repo_add <use_extension variable name> <repo(s)>`:
173173
Ensures that the given repositories are imported via `use_repo` for the
174174
extension for which the given top-level variable contains the return value
175-
of a `use_extension` call.
175+
of a `use_extension` call. Repositories can be specified as:
176+
- Simple names: `foo` generates `use_repo(ext, "foo")`
177+
- Mapped names with valid identifiers: `bar=baz` generates `use_repo(ext, bar = "baz")`
178+
- Mapped names with invalid identifiers (containing dots, hyphens, etc.):
179+
`foo.2=foo` generates `use_repo(ext, **{"foo.2": "foo"})`
176180
* `use_repo_remove <use_extension variable name> <repo(s)>`:
177181
Ensures that the given repositories are *not* imported via `use_repo` for
178182
the extension for which the given top-level variable contains the return
@@ -181,7 +185,9 @@ The following commands only apply to `MODULE.bazel` files (e.g. the target
181185
Ensures that the given repositories generated by the given extension are
182186
imported via `use_repo`. If the `dev` argument is given, extension usages
183187
with `dev_dependency = True` will be considered instead. Extension usages
184-
with `isolated = True` are ignored.
188+
with `isolated = True` are ignored. Supports the same repository name
189+
formats as the first variant (simple, mapped with valid identifiers, and
190+
mapped with invalid identifiers).
185191
* `use_repo_remove [dev] <extension .bzl file> <extension name> <repo(s)>`:
186192
Ensures that the given repositories generated by the given extension are
187193
*not* imported via `use_repo`. If the `dev` argument is given, extension

edit/bzlmod/bzlmod.go

Lines changed: 224 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ package bzlmod
1919

2020
import (
2121
"path"
22+
"strings"
23+
"unicode"
2224

2325
"github.com/bazelbuild/buildtools/build"
2426
"github.com/bazelbuild/buildtools/labels"
@@ -118,10 +120,20 @@ func NewUseRepo(f *build.File, proxies []string) (*build.File, *build.CallExpr)
118120
return &build.File{Path: f.Path, Comments: f.Comments, Stmt: stmt, Type: build.TypeModule}, useRepo
119121
}
120122

123+
// repoToAdd represents a repository to be added to a use_repo call.
124+
type repoToAdd struct {
125+
key string
126+
value string
127+
isMapping bool
128+
isValidIdent bool
129+
}
130+
121131
// AddRepoUsages adds the given repos to the given use_repo calls without introducing duplicate
122132
// arguments.
123133
// useRepos must not be empty.
124-
// Keyword arguments are preserved but adding them is currently not supported.
134+
// Keyword arguments are preserved and can be added using the syntax "key=value".
135+
// For invalid identifiers (e.g., containing dots), dict unpacking syntax is used.
136+
// If a mapping conflicts with an existing one (same key or same value), the existing one is replaced.
125137
func AddRepoUsages(useRepos []*build.CallExpr, repos ...string) {
126138
if len(repos) == 0 {
127139
return
@@ -130,25 +142,170 @@ func AddRepoUsages(useRepos []*build.CallExpr, repos ...string) {
130142
panic("useRepos must not be empty")
131143
}
132144

133-
seen := make(map[string]struct{})
145+
// Parse all repos to add and track which keys/values are being added
146+
var reposToAdd []repoToAdd
147+
mappingValuesToAdd := make(map[string]struct{}) // Values being added as mappings
148+
keysToAdd := make(map[string]struct{})
149+
150+
for _, repo := range repos {
151+
key, value, isMapping := parseRepoMapping(repo)
152+
validIdent := isValidIdentifier(key)
153+
154+
reposToAdd = append(reposToAdd, repoToAdd{
155+
key: key,
156+
value: value,
157+
isMapping: isMapping,
158+
isValidIdent: validIdent,
159+
})
160+
161+
if isMapping {
162+
mappingValuesToAdd[value] = struct{}{}
163+
keysToAdd[key] = struct{}{}
164+
}
165+
}
166+
167+
// Track existing values to detect duplicates when adding positional args
168+
existingValues := collectExistingValues(useRepos)
169+
170+
// Remove conflicting arguments from all use_repo calls
171+
removeConflictingRepos(useRepos, mappingValuesToAdd, keysToAdd)
172+
173+
// Add new repos to the last use_repo call
174+
lastUseRepo := getLastUseRepo(useRepos)
175+
addNewRepos(lastUseRepo, reposToAdd, existingValues)
176+
}
177+
178+
// collectExistingValues returns a set of all repository values currently referenced in use_repo calls.
179+
func collectExistingValues(useRepos []*build.CallExpr) map[string]struct{} {
180+
existingValues := make(map[string]struct{})
134181
for _, useRepo := range useRepos {
135182
if len(useRepo.List) == 0 {
136-
// Invalid use_repo call, skip.
137183
continue
138184
}
139185
for _, arg := range useRepo.List[1:] {
140-
seen[repoFromUseRepoArg(arg)] = struct{}{}
186+
if val := repoFromUseRepoArg(arg); val != "" {
187+
existingValues[val] = struct{}{}
188+
}
141189
}
142190
}
191+
return existingValues
192+
}
143193

144-
lastUseRepo := getLastUseRepo(useRepos)
145-
for _, repo := range repos {
146-
if _, ok := seen[repo]; ok {
194+
// removeConflictingRepos removes arguments from use_repo calls that conflict with repos being added.
195+
func removeConflictingRepos(useRepos []*build.CallExpr, mappingValuesToAdd, keysToAdd map[string]struct{}) {
196+
for _, useRepo := range useRepos {
197+
if len(useRepo.List) == 0 {
147198
continue
148199
}
149-
// Sorting of use_repo arguments is handled by Buildify.
150-
// TODO: Add a keyword argument instead if repo is of the form "key=value".
151-
lastUseRepo.List = append(lastUseRepo.List, &build.StringExpr{Value: repo})
200+
201+
var newArgs []build.Expr
202+
var dictToUpdate *build.DictExpr
203+
204+
for _, arg := range useRepo.List[1:] {
205+
shouldKeep := true
206+
207+
switch arg := arg.(type) {
208+
case *build.StringExpr:
209+
// Positional argument: only remove if a mapping is being added with this value
210+
if _, conflicts := mappingValuesToAdd[arg.Value]; conflicts {
211+
shouldKeep = false
212+
}
213+
case *build.AssignExpr:
214+
// Keyword argument: remove if same key is being added
215+
if ident, ok := arg.LHS.(*build.Ident); ok {
216+
if _, conflicts := keysToAdd[ident.Name]; conflicts {
217+
shouldKeep = false
218+
}
219+
}
220+
// Or if same value is being added as a mapping (replacement)
221+
if str, ok := arg.RHS.(*build.StringExpr); ok {
222+
if _, conflicts := mappingValuesToAdd[str.Value]; conflicts {
223+
shouldKeep = false
224+
}
225+
}
226+
case *build.UnaryExpr:
227+
// Dict unpacking: we'll handle this separately
228+
if arg.Op == "**" {
229+
if dict, ok := arg.X.(*build.DictExpr); ok {
230+
dictToUpdate = dict
231+
shouldKeep = false // Remove from list, we'll re-add after filtering
232+
}
233+
}
234+
}
235+
236+
if shouldKeep {
237+
newArgs = append(newArgs, arg)
238+
}
239+
}
240+
241+
// Filter dict entries if we have a dict to update
242+
if dictToUpdate != nil {
243+
filteredDictEntries := filterDictEntries(dictToUpdate, mappingValuesToAdd, keysToAdd)
244+
// Only keep dict if it still has entries after filtering
245+
if len(filteredDictEntries) > 0 {
246+
dictToUpdate.List = filteredDictEntries
247+
newArgs = append(newArgs, &build.UnaryExpr{Op: "**", X: dictToUpdate})
248+
}
249+
}
250+
251+
useRepo.List = append(useRepo.List[:1], newArgs...)
252+
}
253+
}
254+
255+
// filterDictEntries removes dict entries that conflict with repos being added.
256+
func filterDictEntries(dict *build.DictExpr, mappingValuesToAdd, keysToAdd map[string]struct{}) []*build.KeyValueExpr {
257+
var filteredDictEntries []*build.KeyValueExpr
258+
for _, kv := range dict.List {
259+
shouldKeep := true
260+
// Check if key conflicts
261+
if keyStr, ok := kv.Key.(*build.StringExpr); ok {
262+
if _, conflicts := keysToAdd[keyStr.Value]; conflicts {
263+
shouldKeep = false
264+
}
265+
}
266+
// Check if value conflicts with a mapping being added
267+
if valStr, ok := kv.Value.(*build.StringExpr); ok {
268+
if _, conflicts := mappingValuesToAdd[valStr.Value]; conflicts {
269+
shouldKeep = false
270+
}
271+
}
272+
if shouldKeep {
273+
filteredDictEntries = append(filteredDictEntries, kv)
274+
}
275+
}
276+
return filteredDictEntries
277+
}
278+
279+
// addNewRepos adds new repository arguments to the given use_repo call.
280+
func addNewRepos(useRepo *build.CallExpr, reposToAdd []repoToAdd, existingValues map[string]struct{}) {
281+
var dictEntries []*build.KeyValueExpr
282+
283+
for _, repo := range reposToAdd {
284+
if !repo.isMapping {
285+
// Simple positional argument: use_repo(ext, "foo")
286+
// Skip if already exists (to preserve existing mappings)
287+
if _, exists := existingValues[repo.value]; !exists {
288+
useRepo.List = append(useRepo.List, &build.StringExpr{Value: repo.value})
289+
}
290+
} else if repo.isValidIdent {
291+
// Valid identifier as keyword argument: use_repo(ext, bar = "baz")
292+
useRepo.List = append(useRepo.List, &build.AssignExpr{
293+
LHS: &build.Ident{Name: repo.key},
294+
Op: "=",
295+
RHS: &build.StringExpr{Value: repo.value},
296+
})
297+
} else {
298+
// Invalid identifier, need dict unpacking: use_repo(ext, **{"foo.2": "foo"})
299+
dictEntries = append(dictEntries, &build.KeyValueExpr{
300+
Key: &build.StringExpr{Value: repo.key},
301+
Value: &build.StringExpr{Value: repo.value},
302+
})
303+
}
304+
}
305+
306+
// If we have dict entries, add or extend the **kwargs dict
307+
if len(dictEntries) > 0 {
308+
addOrExtendDictUnpack(useRepo, dictEntries)
152309
}
153310
}
154311

@@ -432,3 +589,60 @@ func collectApparentNamesAndIncludes(f *build.File) (map[string]string, []string
432589

433590
return apparentNames, includeLabels
434591
}
592+
593+
// parseRepoMapping parses a repo string which may be in the form "key=value" or just "value".
594+
// Returns (key, value, isMapping) where isMapping indicates if it was a mapping.
595+
func parseRepoMapping(repo string) (key, value string, isMapping bool) {
596+
parts := strings.SplitN(repo, "=", 2)
597+
if len(parts) == 2 {
598+
return parts[0], parts[1], true
599+
}
600+
return "", repo, false
601+
}
602+
603+
// isValidIdentifier checks if a string is a valid Python identifier.
604+
// Per Python rules: must start with letter or underscore, followed by letters, digits, or underscores.
605+
func isValidIdentifier(s string) bool {
606+
if len(s) == 0 {
607+
return false
608+
}
609+
610+
for i, r := range s {
611+
if i == 0 {
612+
// First character must be letter or underscore
613+
if !unicode.IsLetter(r) && r != '_' {
614+
return false
615+
}
616+
} else {
617+
// Subsequent characters must be letter, digit, or underscore
618+
if !unicode.IsLetter(r) && !unicode.IsDigit(r) && r != '_' {
619+
return false
620+
}
621+
}
622+
}
623+
624+
return true
625+
}
626+
627+
// addOrExtendDictUnpack adds dict entries to an existing **kwargs dict unpacking expression,
628+
// or creates a new one if none exists.
629+
func addOrExtendDictUnpack(useRepo *build.CallExpr, newEntries []*build.KeyValueExpr) {
630+
// Look for an existing **dict unpacking expression
631+
for _, arg := range useRepo.List {
632+
if unary, ok := arg.(*build.UnaryExpr); ok && unary.Op == "**" {
633+
if dict, ok := unary.X.(*build.DictExpr); ok {
634+
// Found existing dict unpacking, add new entries to it
635+
dict.List = append(dict.List, newEntries...)
636+
return
637+
}
638+
}
639+
}
640+
641+
// No existing **dict found, create a new one
642+
useRepo.List = append(useRepo.List, &build.UnaryExpr{
643+
Op: "**",
644+
X: &build.DictExpr{
645+
List: newEntries,
646+
},
647+
})
648+
}

0 commit comments

Comments
 (0)