Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

proposal: x/exp/maps: additional functionality, also a new package x/exp/sets #56128

Closed
adnsv opened this issue Oct 10, 2022 · 11 comments
Closed

Comments

@adnsv
Copy link

adnsv commented Oct 10, 2022

I written a little generics library with a couple of packages to deal with maps and sets. Basically extensions to x/exp/maps and also a separate slices package.

I think this library falls within the scope of x/exp/* and I am willing to donate the code.

Here is the link: https://github.com/adnsv/go-exp

  • github.com/adnsv/go-exp/maps package

    • flattening maps into slices of key-value pairs
    • sorting key-value pairs by key and by value
    • merging maps with conflict resolution
    • inverting maps with duplicate key detection
    • range-for loops for key-ordered or value-ordered iterating over existing maps
  • github.com/adnsv/go-exp/sets package

    • implements intersect, union, difference, and other set-like functionality for key-only maps: map[K]struct{}

There is nothing particularly revolutionary there, but I find it extremely helpful in dramatically reducing the amount of boiler plate code often ending up with one-liners with nice descriptive names which are easy to understand.

If you have a chance, please review it. It has a few usage examples and tests implemented.

@adnsv adnsv added the Proposal label Oct 10, 2022
@gopherbot gopherbot added this to the Proposal milestone Oct 10, 2022
@ianlancetaylor
Copy link
Contributor

Thanks. For proposal purposes, it will be easier to review if you can add the exact new API here, as we did in, for example, #47330 and #47331.

@adnsv
Copy link
Author

adnsv commented Oct 10, 2022

here it is:

package sets

// Sets contain unique elements (keys). Effectively sets are implemented as
// key-only maps of empty structs: set[K] = map[K]struct{}

// Contains checks if there is a key in the set.
func Contains[S ~map[K]struct{}, K comparable](s S, k K) bool 

// Contains checks if any of the keys is in the set.
func ContainsAny[S ~map[K]struct{}, K comparable](s S, keys ...K) bool 

// Contains checks if all the keys are in the set.
func ContainsAll[S ~map[K]struct{}, K comparable](s S, keys ...K) bool

// Equal reports whether two sets contain the same keys.
func Equal[S1, S2 ~map[K]struct{}, K comparable](s1 S1, s2 S2) bool

// Clear removes all keys from s.
func Clear[S ~map[K]struct{}, K comparable](s S) 

// Clone returns a copy of s.
func Clone[S ~map[K]struct{}, K comparable](s S) S

// Insert inserts the keys info s.
func Insert[S ~map[K]struct{}, K comparable](s S, keys ...K)

// Remove removes the keys from s.
func Remove[S ~map[K]struct{}, K comparable](s S, keys ...K)

// Union combines keys from s1 and s2 into one set.
// Returns s1 ∪ s2.
func Union[S map[K]struct{}, K comparable](s1 S, s2 S) S 

// Merge inserts keys from src into the dst.
// Effectively, dst = dst ∪ src.
func Merge[S1 ~map[K]struct{}, S2 ~map[K]struct{}, K comparable](dst S1, src S2)

// Difference returns the keys from s1 that are not contained in s2.
// Returns s1 - s2.
func Difference[S map[K]struct{}, K comparable](s1 S, s2 S) S 

// Subtract removes the src keys from the dst.
// Effectively, this is a difference (subtraction) operation: dst = dst - src.
func Subtract[S1 ~map[K]struct{}, S2 ~map[K]struct{}, K comparable](dst S1, src S2) 

// Intersection returns keys that exist in both s1 and s2.
// Effectively: s1 ∩ s2
func Intersection[S map[K]struct{}, K comparable](s1 S, s2 S) S

// Intersect removes keys from dst that are not contained in src.
// Effectively, dst = dst ∩ src
func Intersect[S1 ~map[K]struct{}, S2 ~map[K]struct{}, K comparable](dst S1, src S2)

// Keys returns the keys from s as a slice. The keys will be in an
// indeterminate order.
func Keys[S ~map[K]struct{}, K comparable](s S) []K

// Sorted returns the keys from s as a sorted slice.
func Sorted[S ~map[K]struct{}, K constraints.Ordered](s S) []K 

//-------------------------------------------

package maps

// Pair is a key-value pair that can be used to flatten maps.
type Pair[K any, V any] struct {
	Key K
	Val V
}

// Pairs returns a slice of key-value pairs constructed from m. The pairs will
// be in an indeterminate order.
func Pairs[M ~map[K]V, K comparable, V any](m M) []*Pair[K, V]

// SortedFunc returns a slice of key-value pairs constructed from m and sorted
// as determined by the less function. The sort is stable if the less function
// produces stable results.
func SortedFunc[M ~map[K]V, K comparable, V any](m M, less func(a, b *Pair[K, V]) bool) []*Pair[K, V]

// SortedByKey returns a slice of key-value pairs constructed from m and sorted
// by key. This sort is guaranteed to be stable because the keys are unique.
func SortedByKey[M ~map[K]V, K constraints.Ordered, V any](m M) []*Pair[K, V] 

// SortedByKeyFunc returns a slice of key-value pairs constructed from m and
// sorted by key as determined by the less function. This sort is stable
// provided the less function produces stable results.
func SortedByKeyFunc[M ~map[K]V, K comparable, V any](m M, less func(a, b K) bool) []*Pair[K, V] 

// SortedByVal returns a slice of key-value pairs constructed from m and sorted
// by value. This sort is stable only if there is no duplicates (all values are
// unique).
func SortedByVal[M ~map[K]V, K comparable, V constraints.Ordered](m M) []*Pair[K, V] 

// SortedByValFunc returns a slice of key-value pairs constructed from m and
// sorted by value as determined by the less function. This sort is stable
// provided there is no duplicates (all values are unique) and the less function
// produces stable results.
func SortedByValFunc[M ~map[K]V, K comparable, V any](m M, less func(a, b V) bool) []*Pair[K, V] 

// StableSortedByVal returns a slice of key-value pairs constructed from m and
// sorted by value. For duplicate values, sorting falls back to comparing keys.
// This sort is guaranteed to be stable.
func StableSortedByVal[M ~map[K]V, K constraints.Ordered, V constraints.Ordered](m M) []*Pair[K, V]

// SortedByValFunc returns a slice of key-value pairs constructed from m and
// sorted by value as determined by the less function. For duplicate values,
// sorting falls back to comparing keys. This sort is stable provided the less
// function produces stable results.
func StableSortedByValFunc[M ~map[K]V, K constraints.Ordered, V any](m M, less func(a, b V) bool) []*Pair[K, V]

// Insert copies key-value pairs into m, if the map doesn't already contain an
// elements with equivalent keys.
func Insert[M ~map[K]V, K comparable, V any](m M, pairs ...*Pair[K, V])

// InsertOrOverwrite copies key-value pairs into m, overwriting existing
// elements with equivalent keys.
func InsertOrOverwrite[M ~map[K]V, K comparable, V any](m M, pairs ...*Pair[K, V]) 

// KeySet returns a set constructed from all the keys in m.
func KeySet[M ~map[K]V, K comparable, V any](m M) map[K]struct{} 

// Sliced returns elements from m that exist in s.
func Sliced[M ~map[K]V, S ~map[K]struct{}, K comparable, V any](m M, s S) M 

// Merge copies key/value pairs in src adding them to dst. When a key from src
// is already in dst and the associated values are different, instead of
// overwriting, the whole key/value pair from src is copied to conflicts.
//
// Upon completion of this routine, the caller may analyze the conflicted keys
// and:
//
//   - Discard and reject the conflicts
//   - Overwrite the dst, for example by calling golang.org/x/exp/maps.Copy routine
//   - Implement more granular solution by merging each value individually
//
func Merge[M1 ~map[K]V, M2 ~map[K]V, K comparable, V comparable](dst M1, src M2) (conflicts M2) 

// MergeFunc provides the same functionality as Merge, but uses the allow
// functor to determine if a value can be overwritten.
func MergeFunc[M1 ~map[K]V, M2 ~map[K]V, K comparable, V any](dst M1, src M2, allow func(dstval, srcval V) bool) (conflicts M2)

// CalcMerge calculates statistics for merging src into dst.
//
//   - Data in both dst ad src remains unchanged
//   - Statistics is returned as sets of keys `map[K]struct{}`
//   - Keys in src that are not in dst are returned in the `create` set
//   - Keys/value pairs that are equal in both src and dst are returned in the `overwrite` set
//   - Keys that have different values in src and dst are returned in the `conflicts` set
//
func CalcMerge[M1 ~map[K]V, M2 ~map[K]V, K comparable, V comparable](dst M1, src M2) (create, overwrite, conflicts map[K]struct{})

// CalcMergeFunc provides the same functionality as CalcMerge, but uses the
// allow functor to determine if a value can be overwritten. Notice also, that
// both dst and src maps in CalcMergeFunc are allowed to have different value
// types, which can help in merging heterogeneous data.
func CalcMergeFunc[M1 ~map[K]V1, M2 ~map[K]V2, K comparable, V1, V2 any](dst M1, src M2, allow func(key K, dstval V1, srcval V2) bool) (create, overwrite, conflicts map[K]struct{})

// ValueSet returns a set constructed from all the values in m.
func ValueSet[M ~map[K]V, K comparable, V comparable](m M) map[V]struct{}

// HasDuplicates checks whether m contains duplicates (multiple keys having the
// same value).
func HasDuplicates[M ~map[K]V, K comparable, V comparable](m M) bool 

// Inverted produces inverted map from m. Entries that can not be inverted are
// returned as a set of duplicates, they are excluded from the inverted result.
//
// A strategy for resolving the issues with duplicates then may include
// iterating over the returned set of duplicates, possibly calling the
// MatchValue function to discover which keys are associated to each duplicate
// and taking appropriate actions.
//
func Inverted[M ~map[K]V, K comparable, V comparable](m M) (inverted map[V]K, duplicates map[V]struct{})

// MatchValue returns a set of keys that contain the same value v.
func MatchValue[M ~map[K]V, K comparable, V comparable](m M, v V) (keys map[K]struct{})

@adnsv
Copy link
Author

adnsv commented Oct 10, 2022

also, here are some of the examples:

func ExampleInsert() {
	m := map[int]string{
		1: "one",
		2: "two",
		3: "three",
		6: "six",
	}

	type pair = Pair[int, string]
	Insert(m, &pair{3, "THREE"}, &pair{4, "FOUR"})

	m2 := map[int]string{
		1: "UNO",
		2: "DOS",
		3: "TRES",
		5: "CINCO",
	}
	Insert(m, Pairs(m2)...)
	for _, p := range SortedByKey(m) {
		fmt.Printf("%d: %s\n", p.Key, p.Val)
	}
	// Output:
	// 1: one
	// 2: two
	// 3: three
	// 4: FOUR
	// 5: CINCO
	// 6: six
}

func ExampleInsertOrOverwrite() {
	m := map[int]string{
		1: "one",
		2: "two",
		3: "three",
		6: "six",
	}

	type pair = Pair[int, string]
	InsertOrOverwrite(m, &pair{3, "THREE"}, &pair{4, "FOUR"})

	m2 := map[int]string{
		1: "UNO",
		2: "DOS",
		3: "TRES",
		5: "CINCO",
	}
	InsertOrOverwrite(m, Pairs(m2)...)
	for _, p := range SortedByKey(m) {
		fmt.Printf("%d: %s\n", p.Key, p.Val)
	}
	// Output:
	// 1: UNO
	// 2: DOS
	// 3: TRES
	// 4: FOUR
	// 5: CINCO
	// 6: six
}

func ExampleSortedByKey() {
	m := map[int]string{
		1: "one",
		2: "two",
		3: "three",
		4: "four",
	}

	for _, p := range SortedByKey(m) {
		fmt.Printf("%d: %s\n", p.Key, p.Val)
	}
	// Output:
	// 1: one
	// 2: two
	// 3: three
	// 4: four
}

func ExampleStableSortedByVal() {
	m := map[int]string{
		1: "D",
		2: "C",
		3: "B",
		4: "A",
	}

	for _, p := range StableSortedByVal(m) {
		fmt.Printf("%d: %s\n", p.Key, p.Val)
	}
	// Output:
	// 4: A
	// 3: B
	// 2: C
	// 1: D
}

func ExampleSliced() {
	m := map[int]string{
		1: "one",
		2: "two",
		3: "three",
		4: "four",
	}

	s := map[int]struct{}{
		2: {},
		4: {},
	}

	for _, p := range SortedByKey(Sliced(m, s)) {
		fmt.Printf("%d: %s\n", p.Key, p.Val)
	}
	// Output:
	// 2: two
	// 4: four
}

func ExampleMerge() {
	m := map[int]string{
		1: "one",
		2: "two",
		3: "three",
		4: "four",
	}
	m2 := map[int]string{
		2: "TWO",
		3: "THREE",
		4: "four",
		5: "five",
	}

	conflicts := Merge(m, m2)

	fmt.Printf("\nMERGED\n")
	for _, p := range SortedByKey(m) {
		fmt.Printf("%d: %s\n", p.Key, p.Val)
	}
	fmt.Printf("\nCONFLICTS\n")
	for _, p := range SortedByKey(conflicts) {
		fmt.Printf("%d: %s\n", p.Key, p.Val)
	}
	// Output:
	//
	// MERGED
	// 1: one
	// 2: two
	// 3: three
	// 4: four
	// 5: five
	//
	// CONFLICTS
	// 2: TWO
	// 3: THREE
}

func ExampleInverted() {
	m := map[string]int{
		"one":                      1,
		"two":                      2,
		"three":                    3,
		"fourty two":               42,
		"the answer to everything": 42,
	}

	inverted, duplicates := Inverted(m)

	fmt.Printf("\nINVERTED\n")
	for _, p := range SortedByKey(inverted) {
		fmt.Printf("%d: %s\n", p.Key, p.Val)
	}
	fmt.Printf("\nDUPLICATES\n")
	for v := range duplicates {
		matching_keys := MatchValue(m, v)
		as_slice := sets.Sorted(matching_keys)
		fmt.Printf("%d: %s\n", v, strings.Join(as_slice, ", "))
	}
	// Output:
	//
	// INVERTED
	// 1: one
	// 2: two
	// 3: three
	//
	// DUPLICATES
	// 42: fourty two, the answer to everything
}

@ianlancetaylor
Copy link
Contributor

Thanks. That is a lot of API. These days we would normally start a discussion for something like that. Or write a series of issues for changes. See, for example, #54012 or #54454. We're unlikely simply pick up a large package and add it to the standard library or even x/exp.

But of course you can publish your package and encourage people to use it. Getting adopters is one of the best signals that a package is worth adding to the standard library.

@adnsv
Copy link
Author

adnsv commented Oct 11, 2022

I agree, it looks a bit overwhelming.

Keep in mind, however that things are well separated and can be easily sliced into completely independent modules:

  • sets: 170 LOC, depends on x/exp/constraints/constraints and x/exp/slices
  • map inversion: 50 LOC, no dependencies
  • map merging: 90 LOC, no dependencies
  • key/val pairs: 180 LOC, depends on x/exp/constraints and x/exp/slices, defines one additional type Pair[K,V]

(LOCs counts include comments)

@rsc
Copy link
Contributor

rsc commented Oct 12, 2022

We already have a maps package. If there is a specific function that you can motivate then feel free to file a proposal, but it's overwhelming to have a proposal to add so many functions. That's almost certainly not the right thing to do on its own.

@rsc
Copy link
Contributor

rsc commented Oct 12, 2022

This proposal has been added to the active column of the proposals project
and will now be reviewed at the weekly proposal review meetings.
— rsc for the proposal review group

@adnsv
Copy link
Author

adnsv commented Oct 14, 2022

For your convenience, here is the summary split by functionality:

  • sets
  • map inversion
  • map merging
  • key-value pairs

Sets

// Contains checks if there is a key in the set.
func Contains[S ~map[K]struct{}, K comparable](s S, k K) bool

// Contains checks if any of the keys is in the set.
func ContainsAny[S ~map[K]struct{}, K comparable](s S, keys ...K) bool

// Contains checks if all the keys are in the set.
func ContainsAll[S ~map[K]struct{}, K comparable](s S, keys ...K) bool

// Equal reports whether two sets contain the same keys.
func Equal[S1, S2 ~map[K]struct{}, K comparable](s1 S1, s2 S2) bool

// Clear removes all keys from s.
func Clear[S ~map[K]struct{}, K comparable](s S)

// Clone returns a copy of s.
func Clone[S ~map[K]struct{}, K comparable](s S) S

// Insert inserts the keys info s.
func Insert[S ~map[K]struct{}, K comparable](s S, keys ...K)

// Remove removes the keys from s.
func Remove[S ~map[K]struct{}, K comparable](s S, keys ...K)

// Union combines keys from s1 and s2 into one set.
// Returns s1 ∪ s2.
func Union[S ~map[K]struct{}, K comparable](s1 S, s2 S) S

// Merge inserts keys from src into the dst.
// Effectively, dst = dst ∪ src.
func Merge[S1 ~map[K]struct{}, S2 ~map[K]struct{}, K comparable](dst S1, src S2)

// Difference returns the keys from s1 that are not contained in s2.
// Returns s1 - s2.
func Difference[S ~map[K]struct{}, K comparable](s1 S, s2 S) S

// Subtract removes the src keys from the dst.
// Effectively, this is a difference (subtraction) operation: dst = dst - src.
func Subtract[S1 ~map[K]struct{}, S2 ~map[K]struct{}, K comparable](dst S1, src S2)

// Intersection returns keys that exist in both s1 and s2.
// Effectively: s1 ∩ s2
func Intersection[S ~map[K]struct{}, K comparable](s1 S, s2 S) S

// Intersect removes keys from dst that are not contained in src.
// Effectively, dst = dst ∩ src
func Intersect[S1 ~map[K]struct{}, S2 ~map[K]struct{}, K comparable](dst S1, src S2)

// Keys returns the keys from s as a slice. The keys will be in an
// indeterminate order.
func Keys[S ~map[K]struct{}, K comparable](s S) []K

// Sorted returns the keys from s as a sorted slice.
func Sorted[S ~map[K]struct{}, K constraints.Ordered](s S) []K

Map Inversion

// ValueSet returns a set constructed from all the values in m.
func ValueSet[M ~map[K]V, K comparable, V comparable](m M) map[V]struct{}

// HasDuplicates checks whether m contains duplicates (multiple keys having the
// same value).
func HasDuplicates[M ~map[K]V, K comparable, V comparable](m M) bool

// Inverted produces inverted map from m. Entries that can not be inverted are
// returned as a set of duplicates, they are excluded from the inverted result:
//
// A strategy for resolving the issues with duplicates then may include
// iterating over the returned set of duplicates, possibly calling the
// MatchValue function to discover which keys are associated to each duplicate
// and taking appropriate actions.
//
func Inverted[M ~map[K]V, K comparable, V comparable](m M) (inverted map[V]K, duplicates map[V]struct{})

// MatchValue returns a set of keys that contain the same value v.
func MatchValue[M ~map[K]V, K comparable, V comparable](m M, v V) (keys map[K]struct{})

Map Merging

// Merge copies key/value pairs in src adding them to dst. When a key from src
// is already in dst and the associated values are different, instead of
// overwriting, the whole key/value pair from src is copied to conflicts.
//
// Upon completion of this routine, the caller may analyze the conflicted keys
// and:
//
//   - Discard and reject the conflicts
//   - Overwrite the dst, for example by calling golang.org/x/exp/maps.Copy routine
//   - Implement more granular solution by merging each value individually
//
func Merge[M1 ~map[K]V, M2 ~map[K]V, K comparable, V comparable]
(dst M1, src M2) (conflicts M2) 

// MergeFunc provides the same functionality as Merge, but uses the allow
// functor to determine if a value can be overwritten.
func MergeFunc[M1 ~map[K]V, M2 ~map[K]V, K comparable, V any]
(dst M1, src M2, allow func(dstval, srcval V) bool) (conflicts M2)

// CalcMerge calculates statistics for merging src into dst.
//
//   - Data in both dst and src remains unchanged
//   - Statistics is returned as sets of keys `map[K]struct{}`
//   - Keys in src that are not in dst are returned in the `create` set
//   - Keys/value pairs that are equal in both src and dst are returned in the `overwrite` set
//   - Keys that have different values in src and dst are returned in the `conflicts` set
//
func CalcMerge[M1 ~map[K]V, M2 ~map[K]V, K comparable, V comparable]
(dst M1, src M2) (create, overwrite, conflicts map[K]struct{})

// CalcMergeFunc provides the same functionality as CalcMerge, but uses the
// allow functor to determine if a value can be overwritten. Notice also, that
// both dst and src maps in CalcMergeFunc are allowed to have different value
// types, which can help in merging heterogeneous data.
func CalcMergeFunc[M1 ~map[K]V1, M2 ~map[K]V2, K comparable, V1, V2 any]
(dst M1, src M2, allow func(key K, dstval V1, srcval V2) bool) (create, overwrite, conflicts map[K]struct{})

Key-Value Pairs

// Pair is a key-value pair that can be used to flatten maps.
type Pair[K any, V any] struct {
	Key K
	Val V
}

// Pairs returns a slice of key-value pairs constructed from m. The pairs will
// be in an indeterminate order.
func Pairs[M ~map[K]V, K comparable, V any](m M) []*Pair[K, V]

// SortedFunc returns a slice of key-value pairs constructed from m and sorted
// as determined by the less function. The sort is stable if the less function
// produces stable results.
func SortedFunc[M ~map[K]V, K comparable, V any](m M, less func(a, b *Pair[K, V]) bool) []*Pair[K, V]

// SortedByKey returns a slice of key-value pairs constructed from m and sorted
// by key. This sort is guaranteed to be stable because the keys are unique.
func SortedByKey[M ~map[K]V, K constraints.Ordered, V any](m M) []*Pair[K, V] 

// SortedByKeyFunc returns a slice of key-value pairs constructed from m and
// sorted by key as determined by the less function. This sort is stable
// provided the less function produces stable results.
func SortedByKeyFunc[M ~map[K]V, K comparable, V any](m M, less func(a, b K) bool) []*Pair[K, V]

// SortedByVal returns a slice of key-value pairs constructed from m and sorted
// by value. This sort is stable only if there is no duplicates (all values are
// unique).
func SortedByVal[M ~map[K]V, K comparable, V constraints.Ordered](m M) []*Pair[K, V]

// SortedByValFunc returns a slice of key-value pairs constructed from m and
// sorted by value as determined by the less function. This sort is stable
// provided there is no duplicates (all values are unique) and the less function
// produces stable results.
func SortedByValFunc[M ~map[K]V, K comparable, V any](m M, less func(a, b V) bool) []*Pair[K, V]

// StableSortedByVal returns a slice of key-value pairs constructed from m and
// sorted by value. For duplicate values, sorting falls back to comparing keys.
// This sort is guaranteed to be stable.
func StableSortedByVal[M ~map[K]V, K constraints.Ordered, V constraints.Ordered](m M) []*Pair[K, V]

// SortedByValFunc returns a slice of key-value pairs constructed from m and
// sorted by value as determined by the less function. For duplicate values,
// sorting falls back to comparing keys. This sort is stable provided the less
// function produces stable results.
func StableSortedByValFunc[M ~map[K]V, K constraints.Ordered, V any](m M, less func(a, b V) bool) []*Pair[K, V] 

// Insert copies key-value pairs into m, if the map doesn't already contain an
// elements with equivalent keys.
func Insert[M ~map[K]V, K comparable, V any](m M, pairs ...*Pair[K, V])

// InsertOrOverwrite copies key-value pairs into m, overwriting existing
// elements with equivalent keys.
func InsertOrOverwrite[M ~map[K]V, K comparable, V any](m M, pairs ...*Pair[K, V])

// KeySet returns a set constructed from all the keys in m.
func KeySet[M ~map[K]V, K comparable, V any](m M) map[K]struct{}

// Sliced returns elements from m that exist in s.
func Sliced[M ~map[K]V, S ~map[K]struct{}, K comparable, V any](m M, s S) M

@rsc
Copy link
Contributor

rsc commented Oct 26, 2022

As I noted two weeks ago, this proposal is too large. If there are specific changes to make, please file separate proposals. Please do NOT file 20 proposals. Focus on the specific changes you think are most important.

@rsc
Copy link
Contributor

rsc commented Oct 26, 2022

Based on the discussion above, this proposal seems like a likely decline.
— rsc for the proposal review group

@rsc
Copy link
Contributor

rsc commented Nov 2, 2022

No change in consensus, so declined.
— rsc for the proposal review group

@rsc rsc closed this as completed Nov 2, 2022
@golang golang locked and limited conversation to collaborators Nov 2, 2023
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Projects
None yet
Development

No branches or pull requests

4 participants