Skip to content

Commit f170f48

Browse files
committed
feat: add CollectionSubject.transform
This adds a generic method to perform arbitrary transformations on the collection's value. When performing a transformation, a human friendly description of the transformation is required. When possible, a description is inferred from the args. Fixes #45
1 parent 3d54459 commit f170f48

File tree

8 files changed

+176
-4
lines changed

8 files changed

+176
-4
lines changed

lib/private/BUILD

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ bzl_library(
6363
":int_subject_bzl",
6464
":matching_bzl",
6565
":truth_common_bzl",
66+
":util_bzl",
6667
],
6768
)
6869

lib/private/collection_subject.bzl

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,14 @@ load(
3535
load(":int_subject.bzl", "IntSubject")
3636
load(":matching.bzl", "matching")
3737
load(":truth_common.bzl", "to_list")
38+
load(":util.bzl", "get_function_name")
39+
40+
def _identity(v):
41+
return v
42+
43+
def _always_true(v):
44+
_ = v # @unused
45+
return True
3846

3947
def _collection_subject_new(
4048
values,
@@ -75,6 +83,7 @@ def _collection_subject_new(
7583
not_contains = lambda *a, **k: _collection_subject_not_contains(self, *a, **k),
7684
not_contains_predicate = lambda *a, **k: _collection_subject_not_contains_predicate(self, *a, **k),
7785
offset = lambda *a, **k: _collection_subject_offset(self, *a, **k),
86+
transform = lambda *a, **k: _collection_subject_transform(self, *a, **k),
7887
# keep sorted end
7988
)
8089
self = struct(
@@ -354,6 +363,85 @@ def _collection_subject_offset(self, offset, factory):
354363
meta = self.meta.derive("offset({})".format(offset)),
355364
)
356365

366+
def _collection_subject_transform(
367+
self,
368+
desc = None,
369+
*,
370+
result = None,
371+
loop = None,
372+
filter = None):
373+
"""Transforms a collections's value and returns another CollectionSubject.
374+
375+
This is equivalent to applying a list comprehension over the collection values,
376+
but takes care of propagating context information and wrapping the value
377+
in a `CollectionSubject`.
378+
379+
`transform(result=R, loop=L, filter=F)` is equivalent to
380+
`[R(v) for v in L(collection) if F(v)]`.
381+
382+
Args:
383+
self: implicitly added.
384+
desc: (optional [`str`]) a human-friendly description of the transform
385+
for use in error messages. Required when a description can't be
386+
inferred from the other args. The description can be inferred if the
387+
filter arg is a named function (non-lambda) or Matcher object.
388+
result: (optional [`callable`]) function to transform an element in
389+
the collection. It takes one positional arg, which is the loop
390+
iteration value, and its return value will be the elements new
391+
value. If not specified, the values from the loop iteration are
392+
returned unchanged.
393+
loop: (optional [`callable`]) function to produce values from the
394+
original collection and whose values are iterated over. It takes one
395+
positional arg, which is the orignal collection. If not specified,
396+
the original collection values are iterated over.
397+
filter: (optional [`callable`]) function that decides what values are
398+
included into the result. It takes one positional arg, the value
399+
to match, and returns a bool (True if the value should be included
400+
in the result, False if it should be skipped).
401+
402+
Returns:
403+
[`CollectionSubject`] of the transformed values.
404+
"""
405+
if not desc:
406+
if result or loop:
407+
fail("description required when result or loop used")
408+
409+
if matching.is_matcher(filter):
410+
desc = "filter=" + filter.desc
411+
else:
412+
func_name = get_function_name(filter)
413+
if func_name == "lambda":
414+
fail("description required: description cannot be " +
415+
"inferred from lambdas. Explicitly specify the " +
416+
"description, use a named function for the filter, " +
417+
"or use a Matcher for the filter.")
418+
else:
419+
desc = "filter={}(...)".format(func_name)
420+
421+
result = result or _identity
422+
loop = loop or _identity
423+
424+
if filter:
425+
if matching.is_matcher(filter):
426+
filter_func = filter.match
427+
else:
428+
filter_func = filter
429+
else:
430+
filter_func = _always_true
431+
432+
new_values = [result(v) for v in loop(self.actual) if filter_func(v)]
433+
434+
return _collection_subject_new(
435+
new_values,
436+
meta = self.meta.derive(
437+
"transform()",
438+
details = ["transform: {}".format(desc)],
439+
),
440+
container_name = self.container_name,
441+
sortable = self.sortable,
442+
element_plural_name = self.element_plural_name,
443+
)
444+
357445
# We use this name so it shows up nice in docs.
358446
# buildifier: disable=name-conventions
359447
CollectionSubject = struct(
@@ -369,5 +457,6 @@ CollectionSubject = struct(
369457
new = _collection_subject_new,
370458
not_contains_predicate = _collection_subject_not_contains_predicate,
371459
offset = _collection_subject_offset,
460+
transform = _collection_subject_transform,
372461
# keep sorted end
373462
)

lib/private/expect.bzl

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -120,18 +120,19 @@ def _expect_that_bool(self, value, expr = "boolean"):
120120
meta = self.meta.derive(expr = expr),
121121
)
122122

123-
def _expect_that_collection(self, collection, expr = "collection"):
123+
def _expect_that_collection(self, collection, expr = "collection", **kwargs):
124124
"""Creates a subject for asserting collections.
125125
126126
Args:
127127
self: implicitly added.
128128
collection: The collection (list or depset) to assert.
129129
expr: ([`str`]) the starting "value of" expression to report in errors.
130+
**kwargs: Additional kwargs to pass onto CollectionSubject.new
130131
131132
Returns:
132133
[`CollectionSubject`] object.
133134
"""
134-
return CollectionSubject.new(collection, self.meta.derive(expr))
135+
return CollectionSubject.new(collection, self.meta.derive(expr), **kwargs)
135136

136137
def _expect_that_depset_of_files(self, depset_files):
137138
"""Creates a subject for asserting a depset of files.

lib/private/expect_meta.bzl

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -233,7 +233,7 @@ def _expect_meta_add_failure(self, problem, actual):
233233
if detail
234234
])
235235
if details:
236-
details = "where...\n" + details
236+
details = "where... (most recent context last)\n" + details
237237
msg = """\
238238
in test: {test}
239239
value of: {expr}

lib/private/matching.bzl

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,9 @@ def _match_parts_in_order(string, parts):
183183
return False
184184
return True
185185

186+
def _is_matcher(obj):
187+
return hasattr(obj, "desc") and hasattr(obj, "match")
188+
186189
# For the definition of a `Matcher` object, see `_match_custom`.
187190
matching = struct(
188191
# keep sorted start
@@ -196,5 +199,6 @@ matching = struct(
196199
str_endswith = _match_str_endswith,
197200
str_matches = _match_str_matches,
198201
str_startswith = _match_str_startswith,
202+
is_matcher = _is_matcher,
199203
# keep sorted end
200204
)

lib/private/util.bzl

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,3 +31,5 @@ def get_test_name_from_function(func):
3131
# have private names. This better allows unused tests to be flagged by
3232
# buildifier (indicating a bug or code to delete)
3333
return func_name.strip("_")
34+
35+
get_function_name = get_test_name_from_function

lib/truth.bzl

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,8 +48,8 @@ load("//lib/private:depset_file_subject.bzl", "DepsetFileSubject")
4848
load("//lib/private:expect.bzl", "Expect")
4949
load("//lib/private:int_subject.bzl", "IntSubject")
5050
load("//lib/private:label_subject.bzl", "LabelSubject")
51-
load("//lib/private:str_subject.bzl", "StrSubject")
5251
load("//lib/private:matching.bzl", _matching = "matching")
52+
load("//lib/private:str_subject.bzl", "StrSubject")
5353

5454
# Rather than load many symbols, just load this symbol, and then all the
5555
# asserts will be available.

tests/truth_tests.bzl

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -832,6 +832,81 @@ def _collection_offset_test(env, _target):
832832

833833
_suite.append(collection_offset_test)
834834

835+
def _collection_transform_test(name):
836+
analysis_test(name, impl = _collection_transform_test_impl, target = "truth_tests_helper")
837+
838+
def _collection_transform_test_impl(env, target):
839+
_ = target # @unused
840+
fake_env = _fake_env(env)
841+
starter = truth.expect(fake_env).that_collection(["alan", "bert", "cari"])
842+
843+
actual = starter.transform(
844+
"values that contain a",
845+
filter = lambda v: "a" in v,
846+
)
847+
actual.contains("not-present")
848+
_assert_failure(
849+
fake_env,
850+
[
851+
"transform()",
852+
"0: alan",
853+
"1: cari",
854+
"transform: values that contain a",
855+
],
856+
env = env,
857+
msg = "transform with lambda filter",
858+
)
859+
860+
actual = starter.transform(filter = matching.contains("b"))
861+
actual.contains("not-present")
862+
_assert_failure(
863+
fake_env,
864+
[
865+
"0: bert",
866+
"transform: filter=<contains b>",
867+
],
868+
env = env,
869+
msg = "transform with matcher filter",
870+
)
871+
872+
def contains_c(v):
873+
return "c" in v
874+
875+
actual = starter.transform(filter = contains_c)
876+
actual.contains("not-present")
877+
_assert_failure(
878+
fake_env,
879+
[
880+
"0: cari",
881+
"transform: filter=contains_c(...)",
882+
],
883+
env = env,
884+
msg = "transform with named function filter",
885+
)
886+
887+
actual = starter.transform(
888+
"v.upper(); match even offsets",
889+
result = lambda v: "{}-{}".format(v[0], v[1].upper()),
890+
loop = enumerate,
891+
)
892+
actual.contains("not-present")
893+
_assert_failure(
894+
fake_env,
895+
[
896+
"transform()",
897+
"0: 0-ALAN",
898+
"1: 1-BERT",
899+
"2: 2-CARI",
900+
"transform: v.upper(); match even offsets",
901+
],
902+
env = env,
903+
msg = "transform with all args",
904+
)
905+
906+
_end(env, fake_env)
907+
908+
_suite.append(_collection_transform_test)
909+
835910
def execution_info_test(name):
836911
analysis_test(name, impl = _execution_info_test, target = "truth_tests_helper")
837912

0 commit comments

Comments
 (0)