diff --git a/stix2/base.py b/stix2/base.py index cca5a1f..00caa84 100644 --- a/stix2/base.py +++ b/stix2/base.py @@ -23,6 +23,16 @@ class STIXJSONEncoder(json.JSONEncoder): return super(STIXJSONEncoder, self).default(obj) +def get_required_properties(properties): + for k, v in properties.items(): + if isinstance(v, dict): + if v.get('required'): + yield k + else: # This is a Property subclass + if v.required: + yield k + + class _STIXBase(collections.Mapping): """Base class for STIX object types""" @@ -30,19 +40,53 @@ class _STIXBase(collections.Mapping): def _make_id(cls): return cls._type + "--" + str(uuid.uuid4()) + # TODO: remove this + def _handle_old_style_property(self, prop_name, prop_metadata, kwargs): + cls = self.__class__ + class_name = cls.__name__ + + if prop_name not in kwargs: + if prop_metadata.get('default'): + default = prop_metadata['default'] + if default == NOW: + kwargs[prop_name] = self.__now + else: + kwargs[prop_name] = default(cls) + elif prop_metadata.get('fixed'): + kwargs[prop_name] = prop_metadata['fixed'] + + if prop_metadata.get('validate'): + if (prop_name in kwargs and + not prop_metadata['validate'](cls, kwargs[prop_name])): + msg = prop_metadata.get('error_msg', DEFAULT_ERROR).format( + type=class_name, + field=prop_name, + expected=prop_metadata.get('expected', + prop_metadata.get('default', lambda x: ''))(cls), + ) + raise ValueError(msg) + elif prop_metadata.get('fixed'): + if kwargs[prop_name] != prop_metadata['fixed']: + msg = prop_metadata.get('error_msg', DEFAULT_ERROR).format( + type=class_name, + field=prop_name, + expected=prop_metadata['fixed'] + ) + raise ValueError(msg) + def __init__(self, **kwargs): cls = self.__class__ class_name = cls.__name__ # Use the same timestamp for any auto-generated datetimes - now = get_timestamp() + self.__now = get_timestamp() # Detect any keyword arguments not allowed for a specific type extra_kwargs = list(set(kwargs) - set(cls._properties)) if extra_kwargs: raise TypeError("unexpected keyword arguments: " + str(extra_kwargs)) - required_fields = [k for k, v in cls._properties.items() if v.get('required')] + required_fields = get_required_properties(cls._properties) missing_kwargs = set(required_fields) - set(kwargs) if missing_kwargs: msg = "Missing required field(s) for {type}: ({fields})." @@ -50,34 +94,12 @@ class _STIXBase(collections.Mapping): raise ValueError(msg.format(type=class_name, fields=field_list)) for prop_name, prop_metadata in cls._properties.items(): - if prop_name not in kwargs: - if prop_metadata.get('default'): - default = prop_metadata['default'] - if default == NOW: - kwargs[prop_name] = now - else: - kwargs[prop_name] = default(cls) - elif prop_metadata.get('fixed'): - kwargs[prop_name] = prop_metadata['fixed'] - if prop_metadata.get('validate'): - if (prop_name in kwargs and - not prop_metadata['validate'](cls, kwargs[prop_name])): - msg = prop_metadata.get('error_msg', DEFAULT_ERROR).format( - type=class_name, - field=prop_name, - expected=prop_metadata.get('expected', - prop_metadata.get('default', lambda x: ''))(cls), - ) - raise ValueError(msg) - elif prop_metadata.get('fixed'): - if kwargs[prop_name] != prop_metadata['fixed']: - msg = prop_metadata.get('error_msg', DEFAULT_ERROR).format( - type=class_name, - field=prop_name, - expected=prop_metadata['fixed'] - ) - raise ValueError(msg) + if isinstance(prop_metadata, dict): + self._handle_old_style_property(prop_name, prop_metadata, kwargs) + else: # This is a Property Subclasses + # self.check_property(prop_name, prop_metadata, kwargs) + pass self._inner = kwargs @@ -92,10 +114,13 @@ class _STIXBase(collections.Mapping): # Handle attribute access just like key access def __getattr__(self, name): + if name.startswith('_'): + return super(_STIXBase, self).__getattr__(name) return self.get(name) def __setattr__(self, name, value): - if name != '_inner': + if name != '_inner' and not name.startswith("_STIXBase__"): + print(name) raise ValueError("Cannot modify properties after creation.") super(_STIXBase, self).__setattr__(name, value) diff --git a/stix2/properties.py b/stix2/properties.py new file mode 100644 index 0000000..8ca92d0 --- /dev/null +++ b/stix2/properties.py @@ -0,0 +1,110 @@ +import uuid + + +class Property(object): + """Represent a property of STIX data type. + + Subclasses can define the following attributes as keyword arguments to + __init__(): + + - `required` - If `True`, the property must be provided when creating an + object with that property. No default value exists for these properties. + (Default: `False`) + - `fixed` - This provides a constant default value. Users are free to + provide this value explicity when constructing an object (which allows + you to copy *all* values from an existing object to a new object), but + if the user provides a value other than the `fixed` value, it will raise + an error. This is semantically equivalent to defining both: + - a `validate()` function that checks if the value matches the fixed + value, and + - a `default()` function that returns the fixed value. + (Default: `None`) + + Subclasses can also define the following functions. + + - `def clean(self, value) -> any:` + - Transform `value` into a valid value for this property. This should + raise a ValueError if such no such transformation is possible. + - `def validate(self, value) -> any:` + - check that `value` is valid for this property. This should return + a valid value (possibly modified) for this property, or raise a + ValueError if the value is not valid. + (Default: if `clean` is defined, it will attempt to call `clean` and + return the result or pass on a ValueError that `clean` raises. If + `clean` is not defined, this will return `value` unmodified). + - `def default(self):` + - provide a default value for this property. + - `default()` can return the special value `NOW` to use the current + time. This is useful when several timestamps in the same object need + to use the same default value, so calling now() for each field-- + likely several microseconds apart-- does not work. + + Subclasses can instead provide lambda functions for `clean`, and `default` + as keyword arguments. `validate` should not be provided as a lambda since + lambdas cannot raise their own exceptions. + """ + + def __init__(self, required=False, fixed=None, clean=None, validate=None, default=None): + self.required = required + if fixed: + self.validate = lambda x: x == fixed + self.default = lambda: fixed + if clean: + self.clean = clean + if validate: + self.validate = validate + if default: + self.default = default + + def clean(self, value): + raise NotImplementedError + + def validate(self, value): + try: + value = self.clean(value) + except NotImplemetedError: + pass + return value + + +class TypeProperty(Property): + + def __init__(self, type): + self.type = type + + def validate(self, value): + if value != self.type: + raise ValueError("Invalid type value") + + def default(self): + return self.type + + +class List(Property): + + def __init__(self, contained): + """ + contained should be a type whose constructor creates an object from the value + """ + self.contained = contained + + def validate(self, value): + # TODO: ensure iterable + for item in value: + self.contained.validate(item) + + def clean(self, value): + return [self.contained(x) for x in value] + + +class IDProperty(Property): + + def __init__(self, type): + self.type = type + + def validate(self, value): + # TODO: validate GUID as well + return value.startswith(self.type + "--") + + def default(self): + return self.type + "--" + str(uuid.uuid4()) diff --git a/stix2/test/test_properties.py b/stix2/test/test_properties.py new file mode 100644 index 0000000..85adc34 --- /dev/null +++ b/stix2/test/test_properties.py @@ -0,0 +1,54 @@ +import pytest + +from stix2.properties import Property, IDProperty + + +def test_property(): + p = Property() + + assert p.required is False + + +def test_basic_validate(): + class Prop(Property): + + def validate(self, value): + if value == 42: + return value + else: + raise ValueError("Must be 42") + + p = Prop() + + assert p.validate(42) == 42 + with pytest.raises(ValueError): + p.validate(41) + + +def test_default_field(): + class Prop(Property): + + def default(self): + return 77 + + p = Prop() + + assert p.default() == 77 + + +def test_fixed_property(): + p = Property(fixed="2.0") + + assert p.validate("2.0") is True + assert p.validate("x") is False + assert p.validate(2.0) is False + + assert p.default() == "2.0" + + +def test_id_property(): + idprop = IDProperty('my-type') + + assert idprop.validate('my-type--90aaca8a-1110-5d32-956d-ac2f34a1bd8c') is True + assert idprop.validate('not-my-type--90aaca8a-1110-5d32-956d-ac2f34a1bd8c') is False + assert idprop.validate(idprop.default())