samredai commented on a change in pull request #3407: URL: https://github.com/apache/iceberg/pull/3407#discussion_r738871109
########## File path: python/src/iceberg/partition_spec.py ########## @@ -0,0 +1,289 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +from urllib import parse +from collections import defaultdict +from typing import List, Optional + +from iceberg.partition_field import PartitionField +from iceberg.schema import Schema +from iceberg.types import Type, NestedField +from iceberg.validation_exception import ValidationException + +PARTITION_DATA_ID_START = 1000 + + +class PartitionSpec(object): Review comment: `(object)` is the default so this can be changed to `class PartitionSpec:` ########## File path: python/tests/test_partition_field.py ########## @@ -0,0 +1,29 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +from iceberg.partition_field import PartitionField + + +def test_partition_field_equality(): + part_field_1 = PartitionField(1, 2, "test_1") + part_field_2 = PartitionField(1, 2, "test_1") + assert part_field_1 == part_field_2 + + +def test_partition_field_str(): + part_field_1 = PartitionField(1, 2, "test_1") + assert str(part_field_1) == "2: test_1 (1)" Review comment: Same here: ```py assert str(PartitionField(1, 2, "test_1")) == "2: test_1 (1)" ``` ########## File path: python/src/iceberg/partition_spec.py ########## @@ -0,0 +1,289 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +from urllib import parse +from collections import defaultdict +from typing import List, Optional Review comment: `Optional` imported but unused ########## File path: python/src/iceberg/schema.py ########## @@ -0,0 +1,67 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +from .types import StructType, Type, NestedField + + +class Schema(object): + alias_to_id: dict = None + id_to_field = {} + name_to_id: dict = None + lowercase_name_to_id: dict = None + + def __init__(self, struct: StructType, schema_id: int, identifier_field_ids: [int]): + self._struct = struct + self._schema_id = schema_id + self._identifier_field_ids = identifier_field_ids + + @property + def struct(self): + return self._struct + + @property + def schema_id(self): + return self._schema_id + + @property + def identifier_field_ids(self): + return self._identifier_field_ids + + def _generate_id_to_field(self): + if self.id_to_field is None: + index = 0 + temp_id_to_field = {} + for field in self.struct.fields: + temp_id_to_field[index] = field + index += 1 + self.id_to_field = temp_id_to_field + return self.id_to_field + + def find_type(self, field_id): + field: NestedField = self._generate_id_to_field().get(field_id) Review comment: Instead of type hinting here, how about setting a hint for the return value of `_generate_id_to_field()` in the function signature? ########## File path: python/src/iceberg/partition_spec.py ########## @@ -0,0 +1,289 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +from urllib import parse +from collections import defaultdict +from typing import List, Optional + +from iceberg.partition_field import PartitionField +from iceberg.schema import Schema +from iceberg.types import Type, NestedField +from iceberg.validation_exception import ValidationException + +PARTITION_DATA_ID_START = 1000 + + +class PartitionSpec(object): + fields_by_source_id: defaultdict[list] = None + field_list: List[PartitionField] = None Review comment: These aren't needed until an individual instance is initialized right? I think they should be arguments to `__init__()`. A few reasons being that it's unnecessary noise when doing `dir(PartitionSpec)` since you don't expect a user to set/get them at the class scope, and also the `help()` documentation could be misleading as most users will refer to `__init__()` as a comprehensive requirement for initialization and these would be missing there. ########## File path: python/tests/test_partition_field.py ########## @@ -0,0 +1,29 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +from iceberg.partition_field import PartitionField Review comment: (incoming opinion) I picked up a style from someone recently to always import the full module being tested so that the tests more obviously present what part of the source code is being tested. I was initially hesitant about the slight increase in verbosity but I've come to find that it is actually super helpful when done consistently throughout. So what do you think about keeping this `from iceberg import partitioning` (after consolidating the source code into a `partitioning.py` file as suggested) and then using `partitioning.PartitionField` and `partitioning.PartitionSpec` through the tests? ########## File path: python/src/iceberg/schema.py ########## @@ -0,0 +1,67 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +from .types import StructType, Type, NestedField + + +class Schema(object): + alias_to_id: dict = None + id_to_field = {} + name_to_id: dict = None + lowercase_name_to_id: dict = None + + def __init__(self, struct: StructType, schema_id: int, identifier_field_ids: [int]): + self._struct = struct + self._schema_id = schema_id + self._identifier_field_ids = identifier_field_ids + + @property + def struct(self): + return self._struct + + @property + def schema_id(self): + return self._schema_id + + @property + def identifier_field_ids(self): + return self._identifier_field_ids + + def _generate_id_to_field(self): + if self.id_to_field is None: Review comment: Since anything falsey is unacceptable here, maybe change this to ```py if not self.id_to_field: ``` ########## File path: python/src/iceberg/partition_spec.py ########## @@ -0,0 +1,289 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +from urllib import parse +from collections import defaultdict +from typing import List, Optional + +from iceberg.partition_field import PartitionField +from iceberg.schema import Schema +from iceberg.types import Type, NestedField +from iceberg.validation_exception import ValidationException + +PARTITION_DATA_ID_START = 1000 + + +class PartitionSpec(object): + fields_by_source_id: defaultdict[list] = None + field_list: List[PartitionField] = None + + def __init__( + self, + schema: Schema, + spec_id: int, + part_fields: List[PartitionField], + last_assigned_field_id: int, + ): + self._schema = schema + self._spec_id = spec_id + self._fields = [part_field for part_field in part_fields] + self._last_assigned_field_id = last_assigned_field_id + + @property + def schema(self) -> Schema: + return self._schema + + @property + def spec_id(self) -> int: + return self._spec_id + + @property + def fields(self) -> List[PartitionField]: + return self._fields + + @property + def last_assigned_field_id(self) -> int: + return self._last_assigned_field_id + + def is_partitioned(self): + return len(self.fields) < 1 + + def _generate_fields_by_source_id(self): + if self.fields_by_source_id is None: + fields_source_to_field_dict = defaultdict(list) + for field in self.fields: + fields_source_to_field_dict[field.source_id] = [field] + return fields_source_to_field_dict + return None + + def get_fields_by_source_id(self, field_id: int) -> List[PartitionField]: + return self._generate_fields_by_source_id().get(field_id, None) + + def __eq__(self, other): + if isinstance(other, PartitionSpec): + if self.spec_id != other.spec_id: + return False + return self.fields == other.fields + return False + + def __str__(self): + partition_spec_str = "[" + for field in self.fields: + partition_spec_str += "\n" + partition_spec_str += " " + field + if len(self.fields) > 0: + partition_spec_str += "\n" + partition_spec_str += "]" + return partition_spec_str + + def compatible_with(self, other): + if self.__eq__(other): + return True + + if len(self.fields) != len(other.fields): + return False + + index = 0 + for field in self.fields: + other_field: PartitionField = other.fields[index] + if ( + field.source_id != other_field.source_id + or field.name != other_field.name + ): + index += 1 + # TODO: Add transform check + return False + return True + + def partition_type(self): + struct_fields = [] + # TODO: Needs transform + pass + + def escape(self, input_str): + try: + return parse.urlencode(input_str, encoding="utf-8") + except TypeError as e: + raise e + + def partition_to_path(self): + # TODO: Needs transform + pass + + def _generate_unpartitioned_spec(self): + return PartitionSpec( + schema=Schema(), + spec_id=0, + part_fields=[], + last_assigned_field_id=PARTITION_DATA_ID_START - 1, + ) + + def unpartitioned(self) -> PartitionSpec: + return self._generate_unpartitioned_spec() + + def check_compatibility(self, spec: PartitionSpec, schema: Schema): + for field in spec.fields: + source_type = schema.find_type(field.source_id) + ValidationException().check( + source_type != None, + f"Cannot find source column for partition field: {field}", + ) + ValidationException().check( + source_type.is_primitive, + f"Cannot partition by non-primitive source field: {source_type}", + ) + # TODO: Add transform check + + def has_sequential_ids(self, spec: PartitionSpec): + index = 0 + for field in spec.fields: + if field.field_id != PARTITION_DATA_ID_START + index: + return False + index += 1 + return True + + +class AtomicInteger: + # TODO: Move to utils + def __init__(self, value=0): + self._value = int(value) + self._lock = threading.Lock() + + def inc(self, d=1): + with self._lock: + self._value += int(d) + return self._value + + def dec(self, d=1): + return self.inc(-d) + + @property + def value(self): + with self._lock: + return self._value + + @value.setter + def value(self, v): + with self._lock: + self._value = int(v) + return self._value + + +class Builder(object): + schema: Schema = None + fields: List[PartitionField] = [] + partition_names = set() + dedup_fields = dict() + spec_id = 0 + last_assigned_field_id = AtomicInteger(value=PARTITION_DATA_ID_START - 1) + + def __init__(self, schema: Schema): + self._schema = schema + + def builder_for(self, schema): + return Builder(schema=schema) + + def next_field_id(self): + return self.last_assigned_field_id.inc() + + def check_and_add_partition_name(self, name: str): + # return check_and_add_partition_name(name, None) + # TODO: needs more schema methods + pass + + def check_and_add_partition_name(self, name: str, source_column_id: int): + # TODO: needs more schema methods + pass + + def check_for_redundant_partitions(self, field: PartitionField): + # TODO: needs transforms + pass + + def with_spec_id(self, new_spec_id: int) -> Builder: + self.spec_id = new_spec_id + return self + + def find_source_column(self, source_name: str) -> NestedField: + # TODO: needs schema.find_field + pass + + def identity(self, source_name: str, target_name: str) -> Builder: + # TODO: needs check_for_redundant_partitions + pass + + def identity(self, source_name: str) -> Builder: + return self.identity(source_name, source_name) + + def year(self, source_name: str, target_name: str) -> Builder: + # TODO: needs check_for_redundant_partitions + pass + + def year(self, source_name: str) -> Builder: + return self.year(source_name, source_name + "_year") + + def month(self, source_name: str, target_name: str) -> Builder: + # TODO: needs check_for_redundant_partitions + pass + + def month(self, source_name: str) -> Builder: + return self.month(source_name, source_name + "_month") + + def day(self, source_name: str, target_name: str) -> Builder: + # TODO: needs check_for_redundant_partitions + pass + + def day(self, source_name: str) -> Builder: + return self.day(source_name, source_name + "_day") + + def hour(self, source_name: str, target_name: str) -> Builder: + # TODO: needs check_for_redundant_partitions + pass + + def hour(self, source_name: str) -> Builder: + return self.hour(source_name, source_name + "_hour") + + def bucket(self, source_name: str, num_buckets: int, target_name: str) -> Builder: + # TODO: needs transforms + pass + + def bucket(self, source_name: str, num_buckets: int) -> Builder: + return self.bucket(source_name, num_buckets, source_name + "_bucket") + + def truncate(self, source_name: str, width: int, target_name: str) -> Builder: + # TODO: needs check_and_add_partition_name and transforms + pass + + def truncate(self, source_name: str, width: int) -> Builder: + return self.truncate(source_name, width, source_name + "_trunc") + + def always_null(self, source_name: str, target_name: str) -> Builder: + # TODO: needs check_and_add_partition_name and transforms + pass + + def always_null(self, source_name: str) -> Builder: + return self.always_null(source_name, source_name + "_null") + + def add(self, source_id: int, field_id: int): + # TODO: needs transforms + # TODO: Two more add methods are needed + pass + + def build(self) -> PartitionSpec: + spec = PartitionSpec( + self.schema, self.spec_id, self.fields, self.last_assigned_field_id + ) + PartitionSpec().check_compatibility(spec, self.schema) Review comment: +1 and I agree that `check_compatibility` should be a function scoped to the package. In general I feel that if `self` is not used then scoping to the package makes more sense. ########## File path: python/src/iceberg/partition_spec.py ########## @@ -0,0 +1,289 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +from urllib import parse +from collections import defaultdict +from typing import List, Optional + +from iceberg.partition_field import PartitionField +from iceberg.schema import Schema +from iceberg.types import Type, NestedField +from iceberg.validation_exception import ValidationException + +PARTITION_DATA_ID_START = 1000 + + +class PartitionSpec(object): + fields_by_source_id: defaultdict[list] = None + field_list: List[PartitionField] = None + + def __init__( + self, + schema: Schema, + spec_id: int, + part_fields: List[PartitionField], + last_assigned_field_id: int, + ): + self._schema = schema + self._spec_id = spec_id + self._fields = [part_field for part_field in part_fields] + self._last_assigned_field_id = last_assigned_field_id + + @property + def schema(self) -> Schema: + return self._schema + + @property + def spec_id(self) -> int: + return self._spec_id + + @property + def fields(self) -> List[PartitionField]: + return self._fields + + @property + def last_assigned_field_id(self) -> int: + return self._last_assigned_field_id + + def is_partitioned(self): + return len(self.fields) < 1 + + def _generate_fields_by_source_id(self): + if self.fields_by_source_id is None: + fields_source_to_field_dict = defaultdict(list) + for field in self.fields: + fields_source_to_field_dict[field.source_id] = [field] + return fields_source_to_field_dict + return None + + def get_fields_by_source_id(self, field_id: int) -> List[PartitionField]: + return self._generate_fields_by_source_id().get(field_id, None) + + def __eq__(self, other): + if isinstance(other, PartitionSpec): + if self.spec_id != other.spec_id: + return False + return self.fields == other.fields + return False + + def __str__(self): + partition_spec_str = "[" + for field in self.fields: + partition_spec_str += "\n" + partition_spec_str += " " + field + if len(self.fields) > 0: + partition_spec_str += "\n" + partition_spec_str += "]" Review comment: These 3 lines can be reduced to: ```py partition_spec_str += "]" if len(self.fields) > 0 else "\n" ``` ########## File path: python/src/iceberg/partition_spec.py ########## @@ -0,0 +1,289 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +from urllib import parse +from collections import defaultdict +from typing import List, Optional + +from iceberg.partition_field import PartitionField +from iceberg.schema import Schema +from iceberg.types import Type, NestedField +from iceberg.validation_exception import ValidationException + +PARTITION_DATA_ID_START = 1000 + + +class PartitionSpec(object): + fields_by_source_id: defaultdict[list] = None + field_list: List[PartitionField] = None + + def __init__( + self, + schema: Schema, + spec_id: int, + part_fields: List[PartitionField], + last_assigned_field_id: int, + ): + self._schema = schema + self._spec_id = spec_id + self._fields = [part_field for part_field in part_fields] + self._last_assigned_field_id = last_assigned_field_id + + @property + def schema(self) -> Schema: + return self._schema + + @property + def spec_id(self) -> int: + return self._spec_id + + @property + def fields(self) -> List[PartitionField]: + return self._fields + + @property + def last_assigned_field_id(self) -> int: + return self._last_assigned_field_id + + def is_partitioned(self): + return len(self.fields) < 1 + + def _generate_fields_by_source_id(self): + if self.fields_by_source_id is None: + fields_source_to_field_dict = defaultdict(list) + for field in self.fields: + fields_source_to_field_dict[field.source_id] = [field] + return fields_source_to_field_dict + return None + + def get_fields_by_source_id(self, field_id: int) -> List[PartitionField]: + return self._generate_fields_by_source_id().get(field_id, None) + + def __eq__(self, other): + if isinstance(other, PartitionSpec): + if self.spec_id != other.spec_id: + return False + return self.fields == other.fields + return False + + def __str__(self): + partition_spec_str = "[" + for field in self.fields: + partition_spec_str += "\n" + partition_spec_str += " " + field + if len(self.fields) > 0: + partition_spec_str += "\n" + partition_spec_str += "]" + return partition_spec_str + + def compatible_with(self, other): + if self.__eq__(other): + return True + + if len(self.fields) != len(other.fields): + return False + + index = 0 + for field in self.fields: + other_field: PartitionField = other.fields[index] Review comment: Instead of adding typing here, doesn't it make more sense to just add it to the function signature? If `other` is typed to `PartitionSpec` than the type hint for fields is implied by the type hint in the `PartitionSpec` constructor where there is: ```py ... part_fields: List[PartitionField], ... ``` ########## File path: python/src/iceberg/schema.py ########## @@ -0,0 +1,67 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +from .types import StructType, Type, NestedField + + +class Schema(object): + alias_to_id: dict = None + id_to_field = {} Review comment: missing a `dict` type hint ########## File path: python/src/iceberg/validation_exception.py ########## @@ -0,0 +1,25 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +class ValidationException(Exception): + def __init__(self, message, *args): + super.__str__(message, *args) + + def check(self, test: bool, message: str, *args): Review comment: I'm hesitant to add logic to a custom exception since it isn't obvious what it's doing in the actual location where the check is happening. How about keeping this as: ```py class ValidationException(Exception): pass ``` and using it as: ```py if x != y: raise ValidationException("custom message here") ``` ########## File path: python/src/iceberg/validation_exception.py ########## @@ -0,0 +1,25 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +class ValidationException(Exception): Review comment: +1 -- This is an automated message from the Apache Git Service. To respond to the message, please log on to GitHub and use the URL above to go to the specific comment. To unsubscribe, e-mail: [email protected] For queries about this service, please contact Infrastructure at: [email protected] --------------------------------------------------------------------- To unsubscribe, e-mail: [email protected] For additional commands, e-mail: [email protected]
