diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index fa68d95..fbb0af7 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -22,10 +22,15 @@ jobs: uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 20 - name: Install dependencies run: | python -m pip install --upgrade pip pip install build + npm install -g jsonld-cli - name: Build package run: | python -m build diff --git a/pyproject.toml b/pyproject.toml index d1c45a4..45c4512 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,7 +31,6 @@ dev = [ "pyshacl >= 0.25.0", "pytest >= 7.4", "pytest-cov >= 4.1", - "pytest-server-fixtures >= 1.7", ] [project.urls] diff --git a/src/shacl2code/context.py b/src/shacl2code/context.py index 85b9138..1551c2c 100644 --- a/src/shacl2code/context.py +++ b/src/shacl2code/context.py @@ -2,15 +2,26 @@ # # SPDX-License-Identifier: MIT +import re +from contextlib import contextmanager + + +def foreach_context(contexts): + for ctx in contexts: + for name, value in ctx.items(): + yield name, value -class Context(object): - from contextlib import contextmanager +class Context(object): def __init__(self, contexts=[]): self.contexts = [c for c in contexts if c] self.__vocabs = [] - self.__expanded = {} - self.__compacted = {} + self.__expanded_iris = {} + self.__expanded_ids = {} + self.__expanded_vocabs = {} + self.__compacted_iris = {} + self.__compacted_ids = {} + self.__compacted_vocabs = {} @contextmanager def vocab_push(self, vocab): @@ -24,144 +35,243 @@ def vocab_push(self, vocab): finally: self.__vocabs.pop() + def __vocab_key(self): + if not self.__vocabs: + return "" + + return self.__vocabs[-1] + def __get_vocab_contexts(self): contexts = [] for v in self.__vocabs: - for ctx in self.contexts: - # Check for vocabulary contexts - for name, value in ctx.items(): - if ( - isinstance(value, dict) - and value["@type"] == "@vocab" - and v == self.__expand(value["@id"], self.contexts) - ): + for name, value in foreach_context(self.contexts): + if ( + isinstance(value, dict) + and value["@type"] == "@vocab" + and v == self.expand_iri(value["@id"]) + ): + if "@context" in value: contexts.insert(0, value["@context"]) - return contexts - - def compact(self, _id): - return self.__compact_contexts(_id) + contexts.extend(self.contexts) - def compact_vocab(self, _id, vocab=None): - with self.vocab_push(vocab): - if not self.__vocabs: - v = "" - else: - v = self.__vocabs[-1] - - return self.__compact_contexts(_id, v, self.__get_vocab_contexts()) - - def __compact_contexts(self, _id, v="", apply_vocabs=False): - if v not in self.__compacted or _id not in self.__compacted[v]: - if apply_vocabs: - contexts = self.__get_vocab_contexts() + self.contexts - else: - contexts = self.contexts - - self.__compacted.setdefault(v, {})[_id] = self.__compact( - _id, - contexts, - apply_vocabs, - ) - return self.__compacted[v][_id] + return contexts - def __compact(self, _id, contexts, apply_vocabs): + def __choose_possible( + self, + term, + default, + contexts, + *, + vocab=False, + base=False, + exact=False, + prefix=False, + ): def remove_prefix(_id, value): + expanded_id = self.expand_iri(_id) + expanded_value = self.expand_iri(value) + possible = set() - if _id.startswith(value): - tmp_id = _id[len(value) :] + if expanded_id.startswith(expanded_value): + tmp_id = _id[len(expanded_value) :] possible.add(tmp_id) - possible |= collect_possible(tmp_id) return possible - def collect_possible(_id): + def helper(term): possible = set() - for ctx in contexts: - for name, value in ctx.items(): - if name == "@vocab": - if apply_vocabs: - possible |= remove_prefix(_id, value) - elif name == "@base": - possible |= remove_prefix(_id, value) - else: - if isinstance(value, dict): - value = value["@id"] - - if _id == value: - possible.add(name) - possible |= collect_possible(name) - elif _id.startswith(value): - tmp_id = name + ":" + _id[len(value) :].lstrip("/") - possible.add(tmp_id) - possible |= collect_possible(tmp_id) + for name, value in foreach_context(contexts): + if name == "@vocab": + if vocab: + possible |= remove_prefix(term, value) + continue + + if name == "@base": + if base: + possible |= remove_prefix(term, value) + continue + + if isinstance(value, dict): + value = value["@id"] + + if term == self.expand_iri(value): + if exact and name not in possible: + possible.add(name) + possible |= helper(name) + continue + + if not prefix: + continue + + if term.startswith(value) and value.endswith("/"): + tmp_id = name + ":" + term[len(value) :].lstrip("/") + if tmp_id not in possible: + possible.add(tmp_id) + possible |= helper(tmp_id) + continue + + if term.startswith(value + ":") and self.expand_iri(value).endswith( + "/" + ): + tmp_id = name + term[len(value) :] + if tmp_id not in possible: + possible.add(tmp_id) + possible |= helper(tmp_id) + continue return possible - possible = collect_possible(_id) + possible = helper(term) + if not possible: - return _id + return default # To select from the possible identifiers, choose the one that has the # least context (fewest ":"), then the shortest, and finally # alphabetically possible = list(possible) possible.sort(key=lambda p: (p.count(":"), len(p), p)) - return possible[0] - def is_relative(self, _id): - import re - - return not re.match(r"[^:]+:", _id) - - def __expand_contexts(self, _id, v="", apply_vocabs=False): - if v not in self.__expanded or _id not in self.__expanded[v]: - if apply_vocabs: - contexts = self.__get_vocab_contexts() + self.contexts - - # Apply contexts - for ctx in contexts: - for name, value in ctx.items(): - if name == "@vocab": - _id = value + _id - else: - contexts = self.contexts + def compact_iri(self, iri): + if iri not in self.__compacted_iris: + self.__compacted_iris[iri] = self.__choose_possible( + iri, + iri, + self.contexts, + exact=True, + prefix=True, + ) - for ctx in contexts: - for name, value in ctx.items(): - if name == "@base" and self.is_relative(_id): - _id = value + _id + return self.__compacted_iris[iri] - self.__expanded.setdefault(v, {})[_id] = self.__expand(_id, contexts) + def compact_id(self, _id): + if ":" not in _id: + return _id - return self.__expanded[v][_id] + if _id not in self.__compacted_ids: + self.__compacted_ids[_id] = self.__choose_possible( + _id, + _id, + self.contexts, + base=True, + prefix=True, + ) - def expand(self, _id): - return self.__expand_contexts(_id) + return self.__compacted_ids[_id] - def expand_vocab(self, _id, vocab=""): + def compact_vocab(self, term, vocab=None): with self.vocab_push(vocab): - if not self.__vocabs: - v = "" - else: - v = self.__vocabs[-1] - - return self.__expand_contexts(_id, v, True) + v = self.__vocab_key() + if v in self.__compacted_vocabs and term in self.__compacted_vocabs[v]: + return self.__compacted_vocabs[v][term] + + compact = self.__choose_possible( + term, + None, + self.__get_vocab_contexts(), + vocab=True, + exact=True, + ) + if compact is not None: + self.__compacted_vocabs.setdefault(v, {})[term] = self.compact_id( + compact + ) + return compact + + # If unable to compact with a vocabulary, compact as an ID + return self.compact_id(term) + + def expand_iri(self, iri): + if iri not in self.__expanded_iris: + self.__expanded_iris[iri] = self.__expand( + iri, + self.contexts, + exact=True, + prefix=True, + ) - def __expand(self, _id, contexts): - for ctx in contexts: - if ":" not in _id: - if _id in ctx: - if isinstance(ctx[_id], dict): - return self.__expand(ctx[_id]["@id"], contexts) - return self.__expand(ctx[_id], contexts) - continue + return self.__expanded_iris[iri] - prefix, suffix = _id.split(":", 1) - if prefix not in ctx: - continue + def expand_id(self, _id): + if _id not in self.__expanded_ids: + self.__expanded_ids[_id] = self.__expand( + _id, + self.contexts, + base=True, + prefix=True, + ) - return self.__expand(prefix, contexts) + suffix + return self.__expanded_ids[_id] - return _id + def expand_vocab(self, term, vocab=None): + with self.vocab_push(vocab): + v = self.__vocab_key() + if v not in self.__expanded_vocabs or term not in self.__expanded_vocabs[v]: + value = self.__expand( + term, + self.__get_vocab_contexts(), + vocab=True, + exact=True, + ) + self.__expanded_vocabs.setdefault(v, {})[term] = self.expand_id(value) + + return self.__expanded_vocabs[v][term] + + def __expand( + self, + term, + contexts, + *, + base=False, + exact=False, + prefix=False, + vocab=False, + ): + def helper(term): + vocabs = [] + bases = [] + prefixes = [] + exacts = [] + is_short = not re.match(r"[^:]+:", term) + + for name, value in foreach_context(contexts): + if name == "@vocab": + if vocab and is_short: + vocabs.append(helper(value)) + continue + + if name == "@base": + if base and is_short: + bases.append(value) + continue + + if isinstance(value, dict): + value = value["@id"] + + if term == name: + if exact: + exacts.append(helper(value)) + continue + + if prefix: + prefixes.append(name) + + for e in exacts: + return e + + if ":" in term: + p, suffix = term.split(":", 1) + for name in prefixes: + if p == name: + p = self.expand_iri(p) + if p.endswith("/"): + return p + suffix + + for value in vocabs + bases: + return value + term + + return term + + return helper(term) diff --git a/src/shacl2code/lang/__init__.py b/src/shacl2code/lang/__init__.py index 20d916e..7589513 100644 --- a/src/shacl2code/lang/__init__.py +++ b/src/shacl2code/lang/__init__.py @@ -9,3 +9,4 @@ from .jinja import JinjaRender # noqa: F401 from .python import PythonRender # noqa: F401 from .jsonschema import JsonSchemaRender # noqa: F401 +from .golang import GolangRender # noqa: F401 diff --git a/src/shacl2code/lang/common.py b/src/shacl2code/lang/common.py index c5abeac..c022dc0 100644 --- a/src/shacl2code/lang/common.py +++ b/src/shacl2code/lang/common.py @@ -72,10 +72,10 @@ def abort_helper(msg): env.globals["abort"] = abort_helper env.globals["SHACL2CODE"] = SHACL2CODE env.globals["SH"] = SH + template = env.get_template(template.name) render = template.render( - disclaimer=f"This file was automatically generated by {os.path.basename(sys.argv[0])}. DO NOT MANUALLY MODIFY IT", **render_args, ) @@ -114,6 +114,14 @@ def _recurse(cls): d.sort() return d + def get_all_named_individuals(cls): + ni = set(i._id for i in cls.named_individuals) + + for d in get_all_derived(cls): + ni |= set(i._id for i in classes.get(d).named_individuals) + + return ni + classes = ObjectList(model.classes) concrete_classes = ObjectList( list(c for c in model.classes if not c.is_abstract) @@ -127,11 +135,13 @@ def _recurse(cls): "abstract_classes": abstract_classes, "enums": enums, "context": model.context, + "disclaimer": f"This file was automatically generated by {os.path.basename(sys.argv[0])}. DO NOT MANUALLY MODIFY IT", **self.get_additional_render_args(), } env = { "get_all_derived": get_all_derived, + "get_all_named_individuals": get_all_named_individuals, **self.get_extra_env(), } diff --git a/src/shacl2code/lang/go_runtime/graph_builder.go b/src/shacl2code/lang/go_runtime/graph_builder.go new file mode 100644 index 0000000..273d546 --- /dev/null +++ b/src/shacl2code/lang/go_runtime/graph_builder.go @@ -0,0 +1,314 @@ +package runtime + +import ( + "fmt" + "reflect" + "strings" +) + +type graphBuilder struct { + ldc ldContext + input []any + graph []any + idPrefix string + nextID map[reflect.Type]int + ids map[reflect.Value]string +} + +func (b *graphBuilder) toGraph() []any { + return b.graph +} + +func (b *graphBuilder) add(o any) (context *serializationContext, err error) { + v := reflect.ValueOf(o) + if v.Type().Kind() != reflect.Pointer { + if v.CanAddr() { + v = v.Addr() + } else { + newV := reflect.New(v.Type()) + newV.Elem().Set(v) + v = newV + } + } + val, err := b.toValue(v) + // objects with IDs get added to the graph during object traversal + if _, isTopLevel := val.(map[string]any); isTopLevel && err == nil { + b.graph = append(b.graph, val) + } + ctx := b.findContext(v.Type()) + return ctx, err +} + +func (b *graphBuilder) findContext(t reflect.Type) *serializationContext { + t = baseType(t) // object may be a pointer, but we want the base types + for _, context := range b.ldc { + for _, typ := range context.iriToType { + if t == typ.typ { + return context + } + } + } + return nil +} + +func (b *graphBuilder) toStructMap(v reflect.Value) (value any, err error) { + t := v.Type() + if t.Kind() != reflect.Struct { + return nil, fmt.Errorf("expected struct type, got: %v", stringify(v)) + } + + meta, ok := fieldByType[ldType](t) + if !ok { + return nil, fmt.Errorf("struct does not have LDType metadata: %v", stringify(v)) + } + + iri := meta.Tag.Get(typeIriCompactTag) + if iri == "" { + iri = meta.Tag.Get(typeIriTag) + } + + context := b.findContext(t) + tc := context.typeToContext[t] + + typeProp := ldTypeProp + if context.typeAlias != "" { + typeProp = context.typeAlias + } + out := map[string]any{ + typeProp: iri, + } + + hasValues := false + id := "" + + for i := 0; i < t.NumField(); i++ { + f := t.Field(i) + if skipField(f) { + continue + } + + prop := f.Tag.Get(propIriCompactTag) + if prop == "" { + prop = f.Tag.Get(propIriTag) + } + + fieldV := v.Field(i) + + if !isRequired(f) && isEmpty(fieldV) { + continue + } + + val, err := b.toValue(fieldV) + if err != nil { + return nil, err + } + + if isIdField(f) { + id, _ = val.(string) + if id == "" { + // if this struct does not have an ID set, and does not have multiple references, + // it is output inline, it does not need an ID, but does need an ID + // when it is moved to the top-level graph and referenced elsewhere + if !b.hasMultipleReferences(v.Addr()) { + continue + } + val, _ = b.ensureID(v.Addr()) + } else if tc != nil { + // compact named IRIs + if _, ok := tc.iriToName[id]; ok { + id = tc.iriToName[id] + } + } + } else { + hasValues = true + } + + out[prop] = val + } + + if id != "" && !hasValues { + // if we _only_ have an ID set and no other values, consider this a named individual + return id, nil + } + + return out, nil +} + +func isIdField(f reflect.StructField) bool { + return f.Tag.Get(propIriTag) == ldIDProp +} + +func isEmpty(v reflect.Value) bool { + return !v.IsValid() || v.IsZero() +} + +func isRequired(f reflect.StructField) bool { + if isIdField(f) { + return true + } + required := f.Tag.Get(propIsRequiredTag) + return required != "" && !strings.EqualFold(required, "false") +} + +func (b *graphBuilder) toValue(v reflect.Value) (any, error) { + if !v.IsValid() { + return nil, nil + } + + switch v.Type().Kind() { + case reflect.Interface: + return b.toValue(v.Elem()) + case reflect.Pointer: + if v.IsNil() { + return nil, nil + } + if !b.hasMultipleReferences(v) { + return b.toValue(v.Elem()) + } + return b.ensureID(v) + case reflect.Struct: + return b.toStructMap(v) + case reflect.Slice: + var out []any + for i := 0; i < v.Len(); i++ { + val, err := b.toValue(v.Index(i)) + if err != nil { + return nil, err + } + out = append(out, val) + } + return out, nil + case reflect.String: + return v.String(), nil + default: + if v.CanInterface() { + return v.Interface(), nil + } + return nil, fmt.Errorf("unable to convert value to maps: %v", stringify(v)) + } +} + +func (b *graphBuilder) ensureID(ptr reflect.Value) (string, error) { + if ptr.Type().Kind() != reflect.Pointer { + return "", fmt.Errorf("expected pointer, got: %v", stringify(ptr)) + } + if id, ok := b.ids[ptr]; ok { + return id, nil + } + + v := ptr.Elem() + t := v.Type() + + id, err := b.getID(v) + if err != nil { + return "", err + } + if id == "" { + if b.nextID == nil { + b.nextID = map[reflect.Type]int{} + } + nextID := b.nextID[t] + 1 + b.nextID[t] = nextID + id = fmt.Sprintf("_:%s-%v", t.Name(), nextID) + } + b.ids[ptr] = id + val, err := b.toValue(v) + if err != nil { + return "", err + } + b.graph = append(b.graph, val) + return id, nil +} + +func (b *graphBuilder) getID(v reflect.Value) (string, error) { + t := v.Type() + if t.Kind() != reflect.Struct { + return "", fmt.Errorf("expected struct, got: %v", stringify(v)) + } + for i := 0; i < t.NumField(); i++ { + f := t.Field(i) + if isIdField(f) { + fv := v.Field(i) + if f.Type.Kind() != reflect.String { + return "", fmt.Errorf("invalid type for ID field %v in: %v", f, stringify(v)) + } + return fv.String(), nil + } + } + return "", nil +} + +// hasMultipleReferences returns true if the ptr value has multiple references in the input slice +func (b *graphBuilder) hasMultipleReferences(ptr reflect.Value) bool { + if !ptr.IsValid() { + return false + } + count := 0 + visited := map[reflect.Value]struct{}{} + for _, v := range b.input { + count += refCountR(ptr, visited, reflect.ValueOf(v)) + if count > 1 { + return true + } + } + return false +} + +// refCount returns the reference count of the value in the container object +func refCount(find any, container any) int { + visited := map[reflect.Value]struct{}{} + ptrV := reflect.ValueOf(find) + if !ptrV.IsValid() { + return 0 + } + return refCountR(ptrV, visited, reflect.ValueOf(container)) +} + +// refCountR recursively searches for the value, find, in the value v +func refCountR(find reflect.Value, visited map[reflect.Value]struct{}, v reflect.Value) int { + if find.Equal(v) { + return 1 + } + if !v.IsValid() { + return 0 + } + if _, ok := visited[v]; ok { + return 0 + } + visited[v] = struct{}{} + switch v.Kind() { + case reflect.Interface: + return refCountR(find, visited, v.Elem()) + case reflect.Pointer: + if v.IsNil() { + return 0 + } + return refCountR(find, visited, v.Elem()) + case reflect.Struct: + count := 0 + for i := 0; i < v.NumField(); i++ { + count += refCountR(find, visited, v.Field(i)) + } + return count + case reflect.Slice: + count := 0 + for i := 0; i < v.Len(); i++ { + count += refCountR(find, visited, v.Index(i)) + } + return count + default: + return 0 + } +} + +func stringify(o any) string { + if v, ok := o.(reflect.Value); ok { + if !v.IsValid() { + return "invalid value" + } + if !v.IsZero() && v.CanInterface() { + o = v.Interface() + } + } + return fmt.Sprintf("%#v", o) +} diff --git a/src/shacl2code/lang/go_runtime/ld_context.go b/src/shacl2code/lang/go_runtime/ld_context.go new file mode 100644 index 0000000..c3fa721 --- /dev/null +++ b/src/shacl2code/lang/go_runtime/ld_context.go @@ -0,0 +1,525 @@ +package runtime + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "path" + "reflect" + "strings" +) + +// ldType is a 0-size data holder property type for type-level linked data +type ldType struct{} + +// ldContext is the holder for all known LD contexts and required definitions +type ldContext map[string]*serializationContext + +// RegisterTypes registers types to be used when serializing/deserialising documents +func (c ldContext) RegisterTypes(contextUrl string, types ...any) ldContext { + ctx := c[contextUrl] + if ctx == nil { + ctx = &serializationContext{ + contextUrl: contextUrl, + typeAlias: "type", // FIXME this needs to come from the LD context + iriToType: map[string]*typeContext{}, + typeToContext: map[reflect.Type]*typeContext{}, + } + c[contextUrl] = ctx + } + for _, typ := range types { + ctx.registerType(typ) + } + return c +} + +// IRIMap registers compact IRIs for the given type +func (c ldContext) IRIMap(contextUrl string, typ any, nameMap map[string]string) ldContext { + c.RegisterTypes(contextUrl) // ensure there is a context created + ctx := c[contextUrl] + + t := reflect.TypeOf(typ) + t = baseType(t) // types should be passed as pointers; we want the base types + tc := ctx.typeToContext[t] + if tc == nil { + ctx.registerType(typ) + tc = ctx.typeToContext[t] + } + for iri, compact := range nameMap { + tc.iriToName[iri] = compact + tc.nameToIri[compact] = iri + } + return c +} + +func (c ldContext) ToJSON(writer io.Writer, value any) error { + vals, err := c.toMaps(value) + if err != nil { + return err + } + enc := json.NewEncoder(writer) + enc.SetEscapeHTML(false) + return enc.Encode(vals) +} + +func (c ldContext) toMaps(o ...any) (values map[string]any, errors error) { + // the ld graph is referenced here + // traverse the go objects to output to the graph + builder := graphBuilder{ + ldc: c, + input: o, + ids: map[reflect.Value]string{}, + } + + var err error + var context *serializationContext + for _, o := range builder.input { + context, err = builder.add(o) + if err != nil { + return nil, err + } + } + + return map[string]any{ + ldContextProp: context.contextUrl, + ldGraphProp: builder.toGraph(), + }, nil +} + +func (c ldContext) FromJSON(reader io.Reader) ([]any, error) { + vals := map[string]any{} + dec := json.NewDecoder(reader) + err := dec.Decode(&vals) + if err != nil { + return nil, err + } + return c.FromMaps(vals) +} + +func (c ldContext) FromMaps(values map[string]any) ([]any, error) { + instances := map[string]reflect.Value{} + + var errs error + var graph []any + + context, _ := values[ldContextProp].(string) + currentContext := c[context] + if currentContext == nil { + return nil, fmt.Errorf("unknown document %s type: %v", ldContextProp, context) + } + + nodes, _ := values[ldGraphProp].([]any) + if nodes == nil { + return nil, fmt.Errorf("%s array not present in root object", ldGraphProp) + } + + // one pass to create all the instances + for _, node := range nodes { + _, err := c.getOrCreateInstance(currentContext, instances, anyType, node) + errs = appendErr(errs, err) + } + + // second pass to fill in all refs + for _, node := range nodes { + got, err := c.getOrCreateInstance(currentContext, instances, anyType, node) + errs = appendErr(errs, err) + if err == nil && got.IsValid() { + graph = append(graph, got.Interface()) + } + } + + return graph, errs +} + +func (c ldContext) getOrCreateInstance(currentContext *serializationContext, instances map[string]reflect.Value, expectedType reflect.Type, incoming any) (reflect.Value, error) { + if isPrimitive(expectedType) { + if convertedVal := convertTo(incoming, expectedType); convertedVal != emptyValue { + return convertedVal, nil + } + return emptyValue, fmt.Errorf("unable to convert incoming value to type %v: %+v", typeName(expectedType), incoming) + } + switch incoming := incoming.(type) { + case string: + instance := c.findById(currentContext, instances, incoming) + if instance != emptyValue { + return instance, nil + } + // not found: have a complex type with string indicates an IRI or other primitive + switch expectedType.Kind() { + case reflect.Pointer: + expectedType = expectedType.Elem() + if isPrimitive(expectedType) { + val, err := c.getOrCreateInstance(currentContext, instances, expectedType, incoming) + if err != nil { + return emptyValue, err + } + instance = reflect.New(expectedType) + instance.Elem().Set(val) + return instance, nil + } + if expectedType.Kind() == reflect.Struct { + return emptyValue, fmt.Errorf("unexpected pointer reference external IRI reference: %v", incoming) + } + fallthrough + case reflect.Struct: + instance = reflect.New(expectedType) + instance = instance.Elem() + err := c.setStructProps(currentContext, instances, instance, map[string]any{ + ldIDProp: incoming, + }) + return instance, err + case reflect.Interface: + return emptyValue, fmt.Errorf("unable to determine appropriate type for external IRI reference: %v", incoming) + default: + } + case map[string]any: + return c.getOrCreateFromMap(currentContext, instances, incoming) + } + return emptyValue, fmt.Errorf("unexpected data type: %#v", incoming) +} + +func convertTo(incoming any, typ reflect.Type) reflect.Value { + v := reflect.ValueOf(incoming) + if v.CanConvert(typ) { + return v.Convert(typ) + } + return emptyValue +} + +func (c ldContext) findById(_ *serializationContext, instances map[string]reflect.Value, incoming string) reflect.Value { + inst, ok := instances[incoming] + if ok { + return inst + } + return emptyValue +} + +func (c ldContext) getOrCreateFromMap(currentContext *serializationContext, instances map[string]reflect.Value, incoming map[string]any) (reflect.Value, error) { + typ, ok := incoming[ldTypeProp].(string) + if !ok && currentContext.typeAlias != "" { + typ, ok = incoming[currentContext.typeAlias].(string) + } + if !ok { + return emptyValue, fmt.Errorf("not a string") + } + + t, ok := currentContext.iriToType[typ] + if !ok { + return emptyValue, fmt.Errorf("don't have type: %v", typ) + } + + id, _ := incoming[ldIDProp].(string) + if id == "" && t.idProp != "" { + id, _ = incoming[t.idProp].(string) + } + inst, ok := instances[id] + if !ok { + inst = reflect.New(baseType(t.typ)) // New(T) returns *T + if id != "" { + // only set instance references when an ID is provided + instances[id] = inst + } + } + + // valid type, make a new one and fill it from the incoming maps + return inst, c.fill(currentContext, instances, inst, incoming) +} + +func (c ldContext) fill(currentContext *serializationContext, instances map[string]reflect.Value, instance reflect.Value, incoming any) error { + switch incoming := incoming.(type) { + case string: + inst := c.findById(currentContext, instances, incoming) + if inst != emptyValue { + return c.setValue(currentContext, instances, instance, inst) + } + // should be an incoming ID if string + return c.setValue(currentContext, instances, instance, map[string]any{ + ldIDProp: incoming, + }) + case map[string]any: + return c.setStructProps(currentContext, instances, instance, incoming) + } + return fmt.Errorf("unsupported incoming data type: %#v attempting to set instance: %#v", incoming, instance.Interface()) +} + +func (c ldContext) setValue(currentContext *serializationContext, instances map[string]reflect.Value, target reflect.Value, incoming any) error { + var errs error + typ := target.Type() + switch typ.Kind() { + case reflect.Slice: + switch incoming := incoming.(type) { + case []any: + return c.setSliceValue(currentContext, instances, target, incoming) + } + // try mapping a single value to an incoming slice + return c.setValue(currentContext, instances, target, []any{incoming}) + case reflect.Struct: + switch incoming := incoming.(type) { + case map[string]any: + return c.setStructProps(currentContext, instances, target, incoming) + case string: + // named individuals just need an object with the iri set + return c.setStructProps(currentContext, instances, target, map[string]any{ + ldIDProp: incoming, + }) + } + case reflect.Interface, reflect.Pointer: + switch incoming := incoming.(type) { + case string, map[string]any: + inst, err := c.getOrCreateInstance(currentContext, instances, typ, incoming) + errs = appendErr(errs, err) + if inst != emptyValue { + target.Set(inst) + return nil + } + } + default: + if newVal := convertTo(incoming, typ); newVal != emptyValue { + target.Set(newVal) + } else { + errs = appendErr(errs, fmt.Errorf("unable to convert %#v to %s, dropping", incoming, typeName(typ))) + } + } + return nil +} + +func (c ldContext) setStructProps(currentContext *serializationContext, instances map[string]reflect.Value, instance reflect.Value, incoming map[string]any) error { + var errs error + typ := instance.Type() + for typ.Kind() == reflect.Pointer { + instance = instance.Elem() + typ = instance.Type() + } + if typ.Kind() != reflect.Struct { + return fmt.Errorf("unable to set struct properties on non-struct type: %#v", instance.Interface()) + } + tc := currentContext.typeToContext[typ] + for i := 0; i < typ.NumField(); i++ { + field := typ.Field(i) + if skipField(field) { + continue + } + fieldVal := instance.Field(i) + + propIRI := field.Tag.Get(propIriTag) + if propIRI == "" { + continue + } + incomingVal, ok := incoming[propIRI] + if !ok { + compactIRI := field.Tag.Get(propIriCompactTag) + if compactIRI != "" { + incomingVal, ok = incoming[compactIRI] + } + } + if !ok { + continue + } + // don't set blank node IDs, these will be regenerated on output + if propIRI == ldIDProp { + if tc != nil { + if str, ok := incomingVal.(string); ok { + if fullIRI, ok := tc.nameToIri[str]; ok { + incomingVal = fullIRI + } + } + } + if isBlankNodeID(incomingVal) { + continue + } + } + errs = appendErr(errs, c.setValue(currentContext, instances, fieldVal, incomingVal)) + } + return errs +} + +func (c ldContext) setSliceValue(currentContext *serializationContext, instances map[string]reflect.Value, target reflect.Value, incoming []any) error { + var errs error + sliceType := target.Type() + if sliceType.Kind() != reflect.Slice { + return fmt.Errorf("expected slice, got: %#v", target) + } + sz := len(incoming) + if sz > 0 { + elemType := sliceType.Elem() + newSlice := reflect.MakeSlice(sliceType, 0, sz) + for i := 0; i < sz; i++ { + incomingValue := incoming[i] + if incomingValue == nil { + continue // don't allow null values + } + newItemValue, err := c.getOrCreateInstance(currentContext, instances, elemType, incomingValue) + errs = appendErr(errs, err) + if newItemValue != emptyValue { + // validate we can actually set the type + if newItemValue.Type().AssignableTo(elemType) { + newSlice = reflect.Append(newSlice, newItemValue) + } + } + } + target.Set(newSlice) + } + return errs +} + +func skipField(field reflect.StructField) bool { + return field.Type.Size() == 0 +} + +func typeName(t reflect.Type) string { + switch { + case isPointer(t): + return "*" + typeName(t.Elem()) + case isSlice(t): + return "[]" + typeName(t.Elem()) + case isMap(t): + return "map[" + typeName(t.Key()) + "]" + typeName(t.Elem()) + case isPrimitive(t): + return t.Name() + } + return path.Base(t.PkgPath()) + "." + t.Name() +} + +func isSlice(t reflect.Type) bool { + return t.Kind() == reflect.Slice +} + +func isMap(t reflect.Type) bool { + return t.Kind() == reflect.Map +} + +func isPointer(t reflect.Type) bool { + return t.Kind() == reflect.Pointer +} + +func isPrimitive(t reflect.Type) bool { + switch t.Kind() { + case reflect.String, + reflect.Int, + reflect.Int8, + reflect.Int16, + reflect.Int32, + reflect.Int64, + reflect.Uint, + reflect.Uint8, + reflect.Uint16, + reflect.Uint32, + reflect.Uint64, + reflect.Float32, + reflect.Float64, + reflect.Bool: + return true + default: + return false + } +} + +const ( + ldIDProp = "@id" + ldTypeProp = "@type" + ldContextProp = "@context" + ldGraphProp = "@graph" + typeIriTag = "iri" + typeIriCompactTag = "iri-compact" + propIriTag = "iri" + propIriCompactTag = "iri-compact" + typeIdPropTag = "id-prop" + propIsRequiredTag = "required" +) + +var ( + emptyValue reflect.Value + anyType = reflect.TypeOf((*any)(nil)).Elem() +) + +type typeContext struct { + typ reflect.Type + iri string + compact string + idProp string + iriToName map[string]string + nameToIri map[string]string +} + +type serializationContext struct { + contextUrl string + typeAlias string + iriToType map[string]*typeContext + typeToContext map[reflect.Type]*typeContext +} + +func fieldByType[T any](t reflect.Type) (reflect.StructField, bool) { + var v T + typ := reflect.TypeOf(v) + for i := 0; i < t.NumField(); i++ { + f := t.Field(i) + if f.Type == typ { + return f, true + } + } + return reflect.StructField{}, false +} + +func (m *serializationContext) registerType(instancePointer any) { + t := reflect.TypeOf(instancePointer) + t = baseType(t) // types should be passed as pointers; we want the base types + + tc := m.typeToContext[t] + if tc != nil { + return // already registered + } + tc = &typeContext{ + typ: t, + iriToName: map[string]string{}, + nameToIri: map[string]string{}, + } + meta, ok := fieldByType[ldType](t) + if ok { + tc.iri = meta.Tag.Get(typeIriTag) + tc.compact = meta.Tag.Get(typeIriCompactTag) + tc.idProp = meta.Tag.Get(typeIdPropTag) + } + for i := 0; i < t.NumField(); i++ { + f := t.Field(i) + if !isIdField(f) { + continue + } + compactIdProp := f.Tag.Get(typeIriCompactTag) + if compactIdProp != "" { + tc.idProp = compactIdProp + } + } + m.iriToType[tc.iri] = tc + m.iriToType[tc.compact] = tc + m.typeToContext[t] = tc +} + +// appendErr appends errors, flattening joined errors +func appendErr(err error, errs ...error) error { + if joined, ok := err.(interface{ Unwrap() []error }); ok { + return errors.Join(append(joined.Unwrap(), errs...)...) + } + if err == nil { + return errors.Join(errs...) + } + return errors.Join(append([]error{err}, errs...)...) +} + +// baseType returns the base type if this is a pointer or interface +func baseType(t reflect.Type) reflect.Type { + switch t.Kind() { + case reflect.Pointer, reflect.Interface: + return baseType(t.Elem()) + default: + return t + } +} + +// isBlankNodeID indicates this is a blank node ID, e.g. _:CreationInfo-1 +func isBlankNodeID(val any) bool { + if val, ok := val.(string); ok { + return strings.HasPrefix(val, "_:") + } + return false +} diff --git a/src/shacl2code/lang/go_runtime/runtime_test.go b/src/shacl2code/lang/go_runtime/runtime_test.go new file mode 100644 index 0000000..2c83224 --- /dev/null +++ b/src/shacl2code/lang/go_runtime/runtime_test.go @@ -0,0 +1,325 @@ +package runtime + +import ( + "bytes" + "encoding/json" + "fmt" + "strings" + "testing" + + "github.com/pmezard/go-difflib/difflib" +) + +/* + SPDX compatible definitions for this test can be generated using something like: + + .venv/bin/python -m shacl2code generate -i https://spdx.org/rdf/3.0.0/spdx-model.ttl -i https://spdx.org/rdf/3.0.0/spdx-json-serialize-annotations.ttl -x https://spdx.org/rdf/3.0.0/spdx-context.jsonld golang --package runtime --output src/shacl2code/lang/go_runtime/generated_code.go --remap-props element=elements,externalIdentifier=externalIdentifiers --include-runtime false +*/ +func Test_spdxExportImportExport(t *testing.T) { + doc := SpdxDocument{ + SpdxId: "old-id", + DataLicense: nil, + Imports: nil, + NamespaceMap: nil, + } + + doc.SetSpdxId("new-id") + + agent := &SoftwareAgent{ + Name: "some-agent", + Summary: "summary", + } + c := &CreationInfo{ + Comment: "some-comment", + Created: "", + CreatedBy: []IAgent{ + agent, + }, + CreatedUsing: []ITool{ + &Tool{ + ExternalIdentifiers: []IExternalIdentifier{ + &ExternalIdentifier{ + ExternalIdentifierType: ExternalIdentifierType_Cpe23, + Identifier: "cpe23:a:myvendor:my-product:*:*:*:*:*:*:*", + }, + }, + Name: "not-tools-golang", + }, + }, + SpecVersion: "", + } + agent.SetCreationInfo(c) + + // add a package + + pkg1 := &Package{ + Name: "some-package-1", + PackageVersion: "1.2.3", + CreationInfo: c, + } + pkg2 := &Package{ + Name: "some-package-2", + PackageVersion: "2.4.5", + CreationInfo: c, + } + doc.Elements = append(doc.Elements, pkg2) + + file1 := &File{ + Name: "/bin/bash", + CreationInfo: c, + } + doc.Elements = append(doc.Elements, file1) + + // add relationships + + doc.Elements = append(doc.Elements, + &Relationship{ + CreationInfo: c, + From: file1, + RelationshipType: RelationshipType_Contains, + To: []IElement{ + pkg1, + pkg2, + }, + }, + ) + + doc.Elements = append(doc.Elements, + &Relationship{ + CreationInfo: c, + From: pkg1, + RelationshipType: RelationshipType_DependsOn, + To: []IElement{ + pkg2, + }, + }, + ) + + doc.Elements = append(doc.Elements, + &AIPackage{ + CreationInfo: c, + TypeOfModel: []string{"a model"}, + }, + ) + + got := encodeDecodeRecode(t, &doc) + + // some basic verification: + + var pkgs []IPackage + for _, e := range got.GetElements() { + if rel, ok := e.(IRelationship); ok && rel.GetRelationshipType() == RelationshipType_Contains { + if from, ok := rel.GetFrom().(IFile); ok && from.GetName() == "/bin/bash" { + for _, el := range rel.GetTo() { + if pkg, ok := el.(IPackage); ok { + pkgs = append(pkgs, pkg) + } + } + + } + } + } + if len(pkgs) != 2 { + t.Error("wrong packages returned") + } +} + +func Test_stringSlice(t *testing.T) { + p := &AIPackage{ + TypeOfModel: []string{"a model"}, + } + encodeDecodeRecode(t, p) +} + +func Test_profileConformance(t *testing.T) { + doc := &SpdxDocument{ + ProfileConformance: []ProfileIdentifierType{ + ProfileIdentifierType_Software, + }, + } + encodeDecodeRecode(t, doc) +} + +func encodeDecodeRecode[T comparable](t *testing.T, obj T) T { + // serialization: + maps, err := ldGlobal.toMaps(obj) + if err != nil { + t.Fatal(err) + } + + buf := bytes.Buffer{} + enc := json.NewEncoder(&buf) + enc.SetEscapeHTML(false) + enc.SetIndent("", " ") + err = enc.Encode(maps) + if err != nil { + t.Fatal(err) + } + + json1 := buf.String() + fmt.Printf("--------- initial JSON: ----------\n%s\n\n", json1) + + // deserialization: + graph, err := ldGlobal.FromJSON(strings.NewReader(json1)) + var got T + for _, entry := range graph { + if e, ok := entry.(T); ok { + got = e + break + } + } + + var empty T + if got == empty { + t.Fatalf("did not find object in graph, json: %s", json1) + } + + // re-serialize: + maps, err = ldGlobal.toMaps(got) + if err != nil { + t.Fatal(err) + } + buf = bytes.Buffer{} + enc = json.NewEncoder(&buf) + enc.SetEscapeHTML(false) + enc.SetIndent("", " ") + err = enc.Encode(maps) + if err != nil { + t.Fatal(err) + } + json2 := buf.String() + fmt.Printf("--------- reserialized JSON: ----------\n%s\n", json2) + + // compare original to parsed and re-encoded + + diff := difflib.UnifiedDiff{ + A: difflib.SplitLines(json1), + B: difflib.SplitLines(json2), + FromFile: "Original", + ToFile: "Current", + Context: 3, + } + text, _ := difflib.GetUnifiedDiffString(diff) + if text != "" { + t.Fatal(text) + } + + return got +} + +func Test_refCount(t *testing.T) { + type O1 struct { + Name string + } + + type O2 struct { + Name string + O1s []*O1 + } + + o1 := &O1{"o1"} + o2 := &O1{"o2"} + o3 := &O1{"o3"} + o21 := &O2{"o21", []*O1{o1, o1, o2, o3}} + o22 := []*O2{ + {"o22-1", []*O1{o1, o1, o1, o1, o2, o3}}, + {"o22-2", []*O1{o1, o1, o1, o1, o2, o3}}, + {"o22-3", []*O1{o1, o1, o1, o1, o2, o3}}, + } + + type O3 struct { + Name string + Ref []*O3 + } + o31 := &O3{"o31", nil} + o32 := &O3{"o32", []*O3{o31}} + o33 := &O3{"o33", []*O3{o32}} + o31.Ref = []*O3{o33} + o34 := &O3{"o34", []*O3{o31, o32}} + o35 := &O3{"o35", []*O3{o31, o32, o31, o32}} + + type O4 struct { + Name string + Ref any + } + o41 := &O4{"o41", nil} + o42 := &O4{"o42", o41} + + tests := []struct { + name string + checkObj any + checkIn any + expected int + }{ + { + name: "none", + checkObj: o33, + checkIn: o21, + expected: 0, + }, + { + name: "interface", + checkObj: o41, + checkIn: o42, + expected: 1, + }, + { + name: "single", + checkObj: o3, + checkIn: o21, + expected: 1, + }, + { + name: "multiple", + checkObj: o1, + checkIn: o21, + expected: 2, + }, + + { + name: "multiple 2", + checkObj: o1, + checkIn: o22, + expected: 12, + }, + { + name: "circular 1", + checkObj: o31, + checkIn: o31, + expected: 1, + }, + { + name: "circular 2", + checkObj: o32, + checkIn: o31, + expected: 1, + }, + { + name: "circular 3", + checkObj: o33, + checkIn: o31, + expected: 1, + }, + { + name: "circular multiple", + checkObj: o32, + checkIn: o34, + expected: 2, + }, + { + name: "circular multiple 2", + checkObj: o32, + checkIn: o35, + expected: 3, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cnt := refCount(tt.checkObj, tt.checkIn) + if cnt != tt.expected { + t.Errorf("wrong reference count: %v != %v", tt.expected, cnt) + } + }) + } +} diff --git a/src/shacl2code/lang/go_runtime/superclass_view.go b/src/shacl2code/lang/go_runtime/superclass_view.go new file mode 100644 index 0000000..1d96e01 --- /dev/null +++ b/src/shacl2code/lang/go_runtime/superclass_view.go @@ -0,0 +1,62 @@ +package runtime + +import ( + "fmt" + "reflect" +) + +// SuperclassView is a helper function to emulate some semblance of inheritance, +// while still having simple structs without embedding, it is highly experimental +func SuperclassView[View any](base any) *View { + var view *View + baseValue := reflect.ValueOf(base) + baseType := baseValue.Type() + validateBaseType(baseType) // base must be a pointer, see usage examples + viewType := reflect.TypeOf(view) + validateFieldAlignment(baseType, viewType) // base memory layout must be compatible with view + view = reflect.NewAt(viewType.Elem(), baseValue.UnsafePointer()).Interface().(*View) + return view +} + +func validateBaseType(base reflect.Type) { + if base.Kind() != reflect.Pointer { + panic(fmt.Errorf("invalid base type; must be a pointer")) + } + if base.Elem().Kind() != reflect.Struct { + panic(fmt.Errorf("invalid base type; must be a pointer to a struct")) + } +} + +func validateFieldAlignment(base, view reflect.Type) { + // should be passed either 2 pointers to struct types or 2 struct types + if base.Kind() == reflect.Pointer && view.Kind() == reflect.Pointer { + base = base.Elem() + view = view.Elem() + } + if base.Kind() != reflect.Struct || view.Kind() != reflect.Struct { + panic(fmt.Errorf("base and view types must point to structs; got base: %s and view: %s", typeName(base), typeName(view))) + } + // view needs to be a subset of the number of fields in base + if view.NumField() > base.NumField() { + panic(fmt.Errorf("view %s (%d fields) is not a subset of %s (%d fields)", typeName(view), view.NumField(), typeName(base), base.NumField())) + } + for i := 0; i < view.NumField(); i++ { + baseField := base.Field(i) + viewField := view.Field(i) + // ignore zero-sized fields + if baseField.Type.Size() == 0 && viewField.Type.Size() == 0 { + continue + } + // field layout must be identical, name _should_ be the same + if baseField.Name != viewField.Name { + panic(fmt.Errorf("field %d in base is named %s but view expects %s", i, baseField.Name, viewField.Name)) + } + if baseField.Type != viewField.Type { + panic(fmt.Errorf("field %d in base is has type %s but view expects %s", i, typeName(baseField.Type), typeName(viewField.Type))) + } + if baseField.Offset != viewField.Offset { + panic(fmt.Errorf("field %d in base is named %d but view expects %d", i, baseField.Offset, viewField.Offset)) + } + // seems to align + } +} diff --git a/src/shacl2code/lang/golang.py b/src/shacl2code/lang/golang.py new file mode 100644 index 0000000..417e1c3 --- /dev/null +++ b/src/shacl2code/lang/golang.py @@ -0,0 +1,321 @@ +# SPDX-License-Identifier: MIT + +from .common import BasicJinjaRender +from .lang import language, TEMPLATE_DIR +from ..model import Property + +import os +import sys +import subprocess +import re +import inspect + +DATATYPES = { + "http://www.w3.org/2001/XMLSchema#string": "string", + "http://www.w3.org/2001/XMLSchema#anyURI": "string", + "http://www.w3.org/2001/XMLSchema#integer": "int", + "http://www.w3.org/2001/XMLSchema#positiveInteger": "uint", # "PInt", + "http://www.w3.org/2001/XMLSchema#nonNegativeInteger": "uint", + "http://www.w3.org/2001/XMLSchema#boolean": "bool", + "http://www.w3.org/2001/XMLSchema#decimal": "float64", + "http://www.w3.org/2001/XMLSchema#dateTime": "string", # "DateTime", + "http://www.w3.org/2001/XMLSchema#dateTimeStamp": "string", # "DateTimeStamp", +} + +RESERVED_WORDS = { + "package" +} + + +@language("golang") +class GolangRender(BasicJinjaRender): + HELP = "Go Language Bindings" + # conform to go:generate format: https://pkg.go.dev/cmd/go#hdr-Generate_Go_files_by_processing_source + disclaimer = f"Code generated by {os.path.basename(sys.argv[0])}. DO NOT EDIT." + + # set during render, for convenience + render_args = None + + # arg defaults: + package_name = "model" + license_id = False + use_embedding = False + getter_prefix = "Get" + setter_prefix = "Set" + export_structs = "true" + interface_prefix = "I" + interface_suffix = "" + struct_prefix = "" + struct_suffix = "" + embedded_prefix = "super_" + pluralize = False + pluralize_length = 3 + include_runtime = "true" + include_view_pointers = False + as_concrete_prefix = "As" + uppercase_constants = True + constant_separator = "_" + remap_props = "" + remap_props_map = {} + + @classmethod + def get_arguments(cls, parser): + super().get_arguments(parser) + parser.add_argument("--package-name", help="Go package name to generate", default=cls.package_name) + parser.add_argument("--license-id", help="SPDX License identifier to include in the generated code", default=cls.license_id) + parser.add_argument("--use-embedding", type=bool, help="use embedded structs", default=cls.use_embedding) + parser.add_argument("--export-structs", help="export structs", default=cls.export_structs) + parser.add_argument("--struct-suffix", help="struct stuffix", default=cls.struct_suffix) + parser.add_argument("--interface-prefix", help="interface prefix", default=cls.interface_prefix) + parser.add_argument("--include-runtime", help="include runtime functions inline", default=cls.include_runtime) + parser.add_argument("--include-view-pointers", type=bool, help="include runtime functions inline", default=cls.include_view_pointers) + parser.add_argument("--disclaimer", help="file header", default=cls.disclaimer) + parser.add_argument("--remap-props", help="property name mapping", default=cls.remap_props) + parser.add_argument("--pluralize", default=cls.pluralize) + + def __init__(self, args): + super().__init__(args, TEMPLATE_DIR / "golang.j2") + for k, v in inspect.getmembers(args): + if k in GolangRender.__dict__ and not k in BasicJinjaRender.__dict__: + setattr(self, k, v) + + def render(self, template, output, *, extra_env={}, render_args={}): + if self.remap_props: + self.remap_props_map = dict(item.split("=") for item in self.remap_props.split(",")) + + class FW: + d = "" + + def write(self, d): + self.d += d + + w = FW() + self.render_args = render_args + super().render(template, w, extra_env=extra_env, render_args=render_args) + formatted = gofmt(w.d) + output.write(formatted) + + def get_extra_env(self): + return { + "trim_iri": trim_iri, + "indent": indent, + "upper_first": upper_first, + "lower_first": lower_first, + "is_array": is_array, + "comment": comment, + } + + def get_additional_render_args(self): + render_args = {} + # add all directly defined functions and variables + for k, v in inspect.getmembers(self): + if k.startswith("_") or k in BasicJinjaRender.__dict__: + continue + render_args[k] = v + return render_args + + def parents(self,cls): + return [self.all_classes().get(parent_id) for parent_id in cls.parent_ids] + + def properties(self,cls): + props = cls.properties + if cls.id_property and not cls.parent_ids: + return [ + Property( + path="@id", + datatype="http://www.w3.org/2001/XMLSchema#string", + min_count=1, # is this accurate? + max_count=1, + varname=cls.id_property, + comment="identifier property", + ), + ] + props + return props + + def all_classes(self): + return self.render_args["classes"] + + def pluralize_name(self,str): + if not self.pluralize: + return str + if len(str) < self.pluralize_length: + return str + if not str.endswith('s'): + return str + "s" + return str + + def struct_prop_name(self,prop): + # prop: + # class_id, comment, datatype, enum_values, max_count, min_count, path, pattern, varname + name = prop.varname + if is_array(prop): + name = self.pluralize_name(name) + + if name in self.remap_props_map: + name = self.remap_props_map[name] + + name = type_name(name) + + if self.export_structs.lower() != "false": + return upper_first(name) + + return lower_first(name) + + def prop_type(self,prop): + # prop: + # class_id, comment, datatype, enum_values, max_count, min_count, path, pattern, varname + if prop.datatype in DATATYPES: + typ = DATATYPES[prop.datatype] + else: + cls = self.all_classes().get(prop.class_id) + if self.requires_interface(cls): + typ = self.interface_name(cls) + else: + typ = self.struct_name(cls) + + return typ + + def parent_has_prop(self, cls, prop): + for parent in self.parents(cls): + for p in self.properties(parent): + if p.varname == prop.varname: + return True + if self.parent_has_prop(parent, prop): + return True + + return False + + def requires_interface(self,cls): + if cls.properties: + return True + if cls.derived_ids: + return True + # if cls.named_individuals: + # return False + # if cls.node_kind == rdflib.term.URIRef('http://www.w3.org/ns/shacl#BlankNodeOrIRI'): + # return True + if cls.parent_ids: + return True + return False + + def include_prop(self, cls, prop): + return not self.parent_has_prop(cls, prop) + + def interface_name(self,cls): + return upper_first(self.interface_prefix + type_name(cls.clsname) + self.interface_suffix) + + def struct_name(self,cls): + name = self.struct_prefix + type_name(cls.clsname) + self.struct_suffix + if self.export_structs: + name = upper_first(name) + else: + name = lower_first(name) + + if name in RESERVED_WORDS: + return name + "_" + + return name + + def pretty_name(self,cls): + return upper_first(type_name(cls.clsname)) + + def constant_var_name(self,named): + if self.uppercase_constants: + return upper_first(named.varname) + return named.varname + + def getter_name(self,prop): + return self.getter_prefix + upper_first(self.struct_prop_name(prop)) + + def setter_name(self,prop): + return self.setter_prefix + upper_first(self.struct_prop_name(prop)) + + def concrete_name(self,cls): + if self.export_structs.lower() != "false": + return self.struct_name(cls) + if self.use_embedding: + return self.embedded_prefix + upper_first(self.struct_name(cls)) + if cls.is_abstract: + return self.struct_name(cls) + return upper_first(self.struct_name(cls)) + + def include_runtime_code(self): + if self.include_runtime.lower() == "false": + return "" + + package_replacer = "package[^\n]+" + import_replacer = "import[^)]+\\)" + code = "" + dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "go_runtime") + with open(os.path.join(dir, "ld_context.go")) as f: + code += re.sub(package_replacer, "", f.read()) + with open(os.path.join(dir, "graph_builder.go")) as f: + code += re.sub(package_replacer, "", re.sub(import_replacer, "", f.read())) + if self.include_view_pointers: + with open(os.path.join(dir, "superclass_view.go")) as f: + code += re.sub(package_replacer, "", re.sub(import_replacer, "", f.read())) + return code + + +# common utility functions + + +def upper_first(str): + return str[0].upper() + str[1:] + + +def lower_first(str): + return str[0].lower() + str[1:] + + +def indent(indent_with, str): + parts = re.split("\n", str) + return indent_with + ("\n"+indent_with).join(parts) + + +def dedent(str, amount): + prefix = ' ' * amount + parts = re.split("\n", str) + for i in range(len(parts)): + if parts[i].startswith(prefix): + parts[i] = parts[i][len(prefix):] + return '\n'.join(parts) + + +def type_name(name): + if isinstance(name, list): + name = "".join(name) + parts = re.split(r'[^a-zA-Z0-9]', name) + part = parts[len(parts)-1] + return upper_first(part) + + +def gofmt(code): + try: + proc = subprocess.Popen(["gofmt"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, stdin=subprocess.PIPE, text=True) + result = proc.communicate(input=code) + if result[0] != "": + return result[0] + return code + except: + return code + + +def is_array(prop): + return prop.max_count is None or prop.max_count > 1 + + +def comment(indent_with, identifier, text): + if text.lower().startswith(identifier.lower()): + text = text[len(identifier):] + text = identifier + " " + lower_first(text) + return indent(indent_with, text) + + +def trim_iri(base,iri): + if not base.endswith("/"): + base += "/" + if False and iri.startswith(base): + return iri[len(base):] + return iri + diff --git a/src/shacl2code/lang/templates/golang.j2 b/src/shacl2code/lang/templates/golang.j2 new file mode 100644 index 0000000..db2a0a5 --- /dev/null +++ b/src/shacl2code/lang/templates/golang.j2 @@ -0,0 +1,151 @@ +// {{ disclaimer }} +{%- if license_id %} +// +// SPDX-License-Identifier: {{ license_id }} +{%- endif %} + +package {{ package_name }} + +{#- include runtime code inline #} +{{- include_runtime_code() }} + +{#- struct_props outputs all properties for concrete struct implementations #} +{%- macro struct_props(cls) %} + {#- there is no embedding, so recursively output all parent properties #} + {%- for parent in parents(cls) %} + {%- if not use_embedding %} + {{- struct_props(parent) }} + {%- else %} + {{ concrete_name(parent) }} + {%- endif %} + {% endfor %} + {#- output direct struct properties #} + {% for prop in properties(cls) %} + {%- if include_prop(cls,prop) %} + {{ comment("// ", struct_prop_name(prop), prop.comment)|indent }} + {{ struct_prop_name(prop) }} {% if is_array(prop) %}[]{% endif %}{{ prop_type(prop) }} `iri:"{{ prop.path }}" iri-compact:"{{ context.compact_vocab(prop.path) }}"` + {%- endif %} + {%- endfor %} +{%- endmacro %} + +{#- struct_funcs outputs all functions for concrete struct implementations #} +{%- macro struct_funcs(base,cls) %} + {#- embedded structs may be expanded props #} + {%- if not use_embedding %} + {%- for parent in parents(cls) %} + {{ struct_funcs(base,parent) }} + {%- endfor %} + {%- endif %} + +{% if requires_interface(cls) and include_view_pointers %} +func (o *{{ struct_name(base) }}) {{ as_concrete_prefix }}{{ concrete_name(cls) }}() *{{ concrete_name(cls) }} { + {%- if base == cls %} + return o + {%- else %} + return SuperclassView[{{ concrete_name(cls) }}](o) + {%- endif %} +} +{%- endif %} + {%- for prop in properties(cls) %} + {%- if include_prop(cls,prop) %} +func (o *{{ struct_name(base) }}) {{ getter_name(prop) }}() {% if is_array(prop) %}[]{% endif %}{{ prop_type(prop) }} { + return o.{{ struct_prop_name(prop) }} +} +func (o *{{ struct_name(base) }}) {{ setter_name(prop) }}(v {% if is_array(prop) %}...{% endif %}{{ prop_type(prop) }}) { + o.{{ struct_prop_name(prop) }} = v +} + {%- endif %} + {%- endfor %} +{%- endmacro %} + +{#- interface_props outputs all interface property definitions #} +{%- macro interface_props(cls) -%} + {#- embedding parent interfaces for proper inheritance #} + {%- for parent in parents(cls) %} + {{- interface_name(parent) }} + {% endfor %} + {%- for prop in properties(cls) %} + {%- if include_prop(cls,prop) %} + {{ comment("// ", getter_name(prop), prop.comment)|indent }} + {{ getter_name(prop) }}() {% if is_array(prop) %}[]{% endif %}{{ prop_type(prop) }} + + {{ setter_name(prop) }}({% if is_array(prop) %}...{% endif %}{{ prop_type(prop) }}) + {% endif %} + {%- endfor %} +{%- endmacro %} + +{#- ------------------------ CONSTRUCTOR ------------------------ #} +{%- macro constructor(cls) %} +func New{{ pretty_name(cls) }}() {{ interface_name(cls) }} { + return &{{ struct_name(cls) }}{} +} +{%- endmacro %} + +{#- ------------------------ INTERFACE ------------------------ #} +{%- macro interface(cls) %} +type {{ interface_name(cls) }} interface { + {{ interface_props(cls) }} +} +{%- endmacro %} + +{#- ------------------------ STRUCT ------------------------ #} +{%- macro struct(cls) %} +type {{ struct_name(cls) }} struct { + _ ldType `iri:"{{ cls._id }}" iri-compact:"{{ context.compact_vocab(cls._id) }}"{% if cls.id_property %} id-prop:"{{ cls.id_property }}"{% endif %}` + {% if not cls.id_property %} + Iri string `iri:"@id"` + {%- endif %} + {{- struct_props(cls) }} +} +{%- endmacro %} + +{#- ------------ CLASSES AND INTERFACES -------------- #} +{%- for cls in classes %} + {#- output the interface definition if required #} + {%- if requires_interface(cls) %} + {{ interface(cls) }} + {%- endif %} + + {#- output the struct definition #} + {{ struct(cls) }} + + {%- if include_view_pointers and concrete_name(cls) != struct_name(cls) %} + type {{ concrete_name(cls) }} = {{ struct_name(cls) }} + {%- endif %} + + {#- output any named constants #} + {%- if cls.named_individuals %} + var ( + {%- for ind in cls.named_individuals %} + {{ pretty_name(cls) }}{{ constant_separator }}{{ constant_var_name(ind) }} {%- if requires_interface(cls) %} {{ interface_name(cls) }} {%- endif %} = {{ struct_name(cls) }}{ {% if not cls.id_property %}Iri{% else %}{{ cls.id_property }}{% endif %}:"{{ trim_iri(cls._id,ind._id) }}" } + {%- endfor %} + ) + {%- endif %} + + {%- if not cls.is_abstract and requires_interface(cls) %} + {{ constructor(cls) }} + {%- endif %} + + {{ struct_funcs(cls,cls) }} +{%- endfor %} + +{#- ------------ type mapping for serialization -------------- #} +var ldGlobal = ldContext{} +{%- for url in context.urls %}. + RegisterTypes("{{ url }}", + {%- for cls in classes %} + &{{ struct_name(cls) }}{}, + {%- endfor %} + ) + {%- for cls in classes %} + {%- for prop in properties(cls) %} + {%- if prop.enum_values -%}. + IRIMap("{{ url }}", &{{ prop_type(prop) }}{}, map[string]string{ + {%- for iri in prop.enum_values %} + "{{ iri }}": "{{ context.compact_vocab(iri, prop.path) }}", + {%- endfor %} + }) + {%- endif %} + {%- endfor %} + {%- endfor %} +{%- endfor %} diff --git a/src/shacl2code/lang/templates/jsonschema.j2 b/src/shacl2code/lang/templates/jsonschema.j2 index 1e18d83..912bf3d 100644 --- a/src/shacl2code/lang/templates/jsonschema.j2 +++ b/src/shacl2code/lang/templates/jsonschema.j2 @@ -59,7 +59,7 @@ {#- Classes are divided into 2 parts. The properties are separated into a separate object #} {#- so that a object can references the properties of its parent without needing the const #} {#- @type tag #} - {%- if not class.is_abstract %} + {%- if not class.is_abstract or class.is_extensible %} "{{ varname(*class.clsname) }}": { "allOf": [ { @@ -69,13 +69,21 @@ {%- endif %} "properties": { "{{ class.id_property or "@id" }}": { "$ref": "#/$defs/{{ class.node_kind.split("#")[-1] }}" }, - "{{ context.compact("@type") }}": { + "{{ context.compact_iri("@type") }}": { + {#- Abstract Extensible classes are weird; any type _except_ the specific class type is allowed #} + {%- if class.is_abstract and class.is_extensible %} + "allOf": [ + { "$ref": "#/$defs/IRI" }, + { "not": { "const": "{{ context.compact_vocab(class._id) }}" } } + ] + {%- else %} "oneOf": [ {%- if class.is_extensible %} { "$ref": "#/$defs/IRI" }, {%- endif %} { "const": "{{ context.compact_vocab(class._id) }}" } ] + {%- endif %} } }{%- if class.node_kind == SH.IRI %}, "required": ["{{ class.id_property or "@id" }}"] @@ -86,9 +94,14 @@ }, {%- endif %} "{{ varname(*class.clsname) }}_derived": { - {%- set ns = namespace(external_ref=False, local_ref=False, any_ref=False, json_refs=[]) %} + {%- set ns = namespace(json_refs=[], named_individuals=[]) %} {%- for d in get_all_derived(class) + [class._id] %} {%- set ns.json_refs = ns.json_refs + ["#/$defs/" + varname(*classes.get(d).clsname)] %} + {%- for n in classes.get(d).named_individuals %} + {%- if context.compact_iri(n._id) != n._id %} + {%- set ns.named_individuals = ns.named_individuals + [context.compact_iri(n._id)] %} + {%- endif %} + {%- endfor %} {%- endfor %} "anyOf": [ {%- if ns.json_refs %} @@ -104,6 +117,9 @@ ] }, {%- endif %} + {%- for n in ns.named_individuals %} + { "const": "{{ n }}" }, + {%- endfor %} { "$ref": "#/$defs/BlankNodeOrIRI" } ] }, @@ -248,22 +264,10 @@ "anyURI": { "type": "string" }, - "DateTime": { - "type": "string" - }, - "MediaType": { - "type": "string" - }, - "SemVer": { - "type": "string" - }, - "Extension": { - "type": "string" - }, "SHACLClass": { "type": "object", "properties": { - "{{ context.compact("@type") }}": { + "{{ context.compact_iri("@type") }}": { "oneOf": [ { "$ref": "#/$defs/IRI" }, { @@ -276,7 +280,7 @@ ] } }, - "required": ["{{ context.compact("@type") }}"] + "required": ["{{ context.compact_iri("@type") }}"] }, "AnyClass": { "anyOf": [ diff --git a/src/shacl2code/lang/templates/python.j2 b/src/shacl2code/lang/templates/python.j2 index 5afa21c..d55bc65 100644 --- a/src/shacl2code/lang/templates/python.j2 +++ b/src/shacl2code/lang/templates/python.j2 @@ -10,8 +10,9 @@ import functools import hashlib import json import re -import time +import sys import threading +import time from contextlib import contextmanager from datetime import datetime, timezone, timedelta from enum import Enum @@ -219,18 +220,39 @@ class FloatProp(Property): return decoder.read_float() -class ObjectProp(Property): +class IRIProp(Property): + def __init__(self, context=[], *, pattern=None): + super().__init__(pattern=pattern) + self.context = context + + def compact(self, value): + for iri, compact in self.context: + if value == iri: + return compact + return None + + def expand(self, value): + for iri, compact in self.context: + if value == compact: + return iri + return None + + def iri_values(self): + return (iri for iri, _ in self.context) + + +class ObjectProp(IRIProp): """ A scalar SHACL object property of a SHACL object """ - def __init__(self, cls, required): - super().__init__() + def __init__(self, cls, required, context=[]): + super().__init__(context) self.cls = cls self.required = required def init(self): - if self.required: + if self.required and not self.cls.IS_ABSTRACT: return self.cls() return None @@ -263,7 +285,7 @@ class ObjectProp(Property): raise ValueError("Object cannot be None") if isinstance(value, str): - encoder.write_iri(value) + encoder.write_iri(value, self.compact(value)) return return value.encode(encoder, state) @@ -273,6 +295,8 @@ class ObjectProp(Property): if iri is None: return self.cls.decode(decoder, objectset=objectset) + iri = self.expand(iri) or iri + if objectset is None: return iri @@ -441,36 +465,27 @@ class ListProp(Property): return ListProxy(self.prop, data=data) -class EnumProp(Property): +class EnumProp(IRIProp): VALID_TYPES = str def __init__(self, values, *, pattern=None): - super().__init__(pattern=pattern) - self.values = values + super().__init__(values, pattern=pattern) def validate(self, value): super().validate(value) - valid_values = (iri for iri, _ in self.values) + valid_values = self.iri_values() if value not in valid_values: raise ValueError( f"'{value}' is not a valid value. Choose one of {' '.join(valid_values)}" ) def encode(self, encoder, value, state): - for iri, compact in self.values: - if iri == value: - encoder.write_enum(value, self, compact) - return - - encoder.write_enum(value, self) + encoder.write_enum(value, self, self.compact(value)) def decode(self, decoder, *, objectset=None): v = decoder.read_enum(self) - for iri, compact in self.values: - if v == compact: - return iri - return v + return self.expand(v) or v class NodeKind(Enum): @@ -497,7 +512,7 @@ def is_blank_node(s): return True -def register(type_iri, compact_type=None): +def register(type_iri, *, compact_type=None, abstract=False): def add_class(key, c): assert ( key not in SHACLObject.CLASSES @@ -505,17 +520,22 @@ def register(type_iri, compact_type=None): SHACLObject.CLASSES[key] = c def decorator(c): + global NAMED_INDIVIDUALS + assert issubclass( c, SHACLObject ), f"{c.__name__} is not derived from SHACLObject" c._OBJ_TYPE = type_iri + c.IS_ABSTRACT = abstract add_class(type_iri, c) c._OBJ_COMPACT_TYPE = compact_type if compact_type: add_class(compact_type, c) + NAMED_INDIVIDUALS |= set(c.NAMED_INDIVIDUALS.values()) + # Registration is deferred until the first instance of class is created # so that it has access to any other defined class c._NEEDS_REG = True @@ -525,6 +545,7 @@ def register(type_iri, compact_type=None): register_lock = threading.Lock() +NAMED_INDIVIDUALS = set() @functools.total_ordering @@ -532,8 +553,14 @@ class SHACLObject(object): CLASSES = {} NODE_KIND = NodeKind.BlankNodeOrIRI ID_ALIAS = None + IS_ABSTRACT = True def __init__(self, **kwargs): + if self._is_abstract(): + raise NotImplementedError( + f"{self.__class__.__name__} is abstract and cannot be implemented" + ) + with register_lock: cls = self.__class__ if cls._NEEDS_REG: @@ -542,15 +569,18 @@ class SHACLObject(object): cls._register_props() cls._NEEDS_REG = False - self._obj_data = {} - self._obj_metadata = {} + self.__dict__["_obj_data"] = {} + self.__dict__["_obj_metadata"] = {} for iri, prop, _, _, _, _ in self.__iter_props(): - self._obj_data[iri] = prop.init() + self.__dict__["_obj_data"][iri] = prop.init() for k, v in kwargs.items(): setattr(self, k, v) + def _is_abstract(self): + return self.__class__.IS_ABSTRACT + @classmethod def _register_props(cls): cls._add_property("_id", StringProp(), iri="@id") @@ -573,15 +603,16 @@ class SHACLObject(object): while hasattr(cls, pyname): pyname = pyname + "_" + pyname = sys.intern(pyname) + iri = sys.intern(iri) + cls._OBJ_IRIS[pyname] = iri cls._OBJ_PROPERTIES[iri] = (prop, min_count, max_count, pyname, compact) def __setattr__(self, name, value): - if name.startswith("_obj_"): - return super().__setattr__(name, value) - if name == self.ID_ALIAS: - name = "_id" + self["@id"] = value + return try: iri = self._OBJ_IRIS[name] @@ -592,35 +623,32 @@ class SHACLObject(object): ) def __getattr__(self, name): - if name.startswith("_obj_"): - return self.__dict__[name] + if name in self._OBJ_IRIS: + return self.__dict__["_obj_data"][self._OBJ_IRIS[name]] + + if name == self.ID_ALIAS: + return self.__dict__["_obj_data"]["@id"] if name == "_metadata": - return self._obj_metadata + return self.__dict__["_obj_metadata"] if name == "_IRI": return self._OBJ_IRIS - if name == self.ID_ALIAS: - name = "_id" - if name == "TYPE": return self.__class__._OBJ_TYPE if name == "COMPACT_TYPE": return self.__class__._OBJ_COMPACT_TYPE - try: - iri = self._OBJ_IRIS[name] - return self[iri] - except KeyError: - raise AttributeError( - f"'{name}' is not a valid property of {self.__class__.__name__}" - ) + raise AttributeError( + f"'{name}' is not a valid property of {self.__class__.__name__}" + ) def __delattr__(self, name): if name == self.ID_ALIAS: - name = "_id" + del self["@id"] + return try: iri = self._OBJ_IRIS[name] @@ -643,7 +671,7 @@ class SHACLObject(object): yield iri, *v def __getitem__(self, iri): - return self._obj_data[iri] + return self.__dict__["_obj_data"][iri] def __setitem__(self, iri, value): if iri == "@id": @@ -665,11 +693,11 @@ class SHACLObject(object): prop, _, _, _, _ = self.__get_prop(iri) prop.validate(value) - self._obj_data[iri] = prop.set(value) + self.__dict__["_obj_data"][iri] = prop.set(value) def __delitem__(self, iri): prop, _, _, _, _ = self.__get_prop(iri) - self._obj_data[iri] = prop.init() + self.__dict__["_obj_data"][iri] = prop.init() def __iter__(self): return self._OBJ_PROPERTIES.keys() @@ -687,7 +715,7 @@ class SHACLObject(object): if callback(self, path): for iri, prop, _, _, _, _ in self.__iter_props(): - prop.walk(self._obj_data[iri], callback, path + [f".{iri}"]) + prop.walk(self.__dict__["_obj_data"][iri], callback, path + [f".{iri}"]) def property_keys(self): for iri, _, _, _, pyname, compact in self.__iter_props(): @@ -704,7 +732,7 @@ class SHACLObject(object): for iri, prop, _, _, _, _ in self.__iter_props(): for c in prop.iter_objects( - self._obj_data[iri], recursive=recursive, visited=visited + self.__dict__["_obj_data"][iri], recursive=recursive, visited=visited ): yield c @@ -730,7 +758,7 @@ class SHACLObject(object): def _encode_properties(self, encoder, state): for iri, prop, min_count, max_count, pyname, compact in self.__iter_props(): - value = self._obj_data[iri] + value = self.__dict__["_obj_data"][iri] if prop.elide(value): if min_count: raise ValueError( @@ -817,7 +845,7 @@ class SHACLObject(object): with decoder.read_property(read_key) as prop_d: v = prop.decode(prop_d, objectset=objectset) prop.validate(v) - self._obj_data[iri] = v + self.__dict__["_obj_data"][iri] = v return True return False @@ -829,8 +857,8 @@ class SHACLObject(object): visited.add(self) for iri, prop, _, _, _, _ in self.__iter_props(): - self._obj_data[iri] = prop.link_prop( - self._obj_data[iri], + self.__dict__["_obj_data"][iri] = prop.link_prop( + self.__dict__["_obj_data"][iri], objectset, missing, visited, @@ -869,11 +897,20 @@ class SHACLExtensibleObject(object): CLOSED = False def __init__(self, typ=None, **kwargs): - super().__init__(**kwargs) if typ: - self._obj_TYPE = (typ, None) + self.__dict__["_obj_TYPE"] = (typ, None) else: - self._obj_TYPE = (self._OBJ_TYPE, self._OBJ_COMPACT_TYPE) + self.__dict__["_obj_TYPE"] = (self._OBJ_TYPE, self._OBJ_COMPACT_TYPE) + super().__init__(**kwargs) + + def _is_abstract(self): + # Unknown classes are assumed to not be abstract so that they can be + # deserialized + typ = self.__dict__["_obj_TYPE"][0] + if typ in self.__class__.CLASSES: + return self.__class__.CLASSES[typ].IS_ABSTRACT + + return False @classmethod def _make_object(cls, typ): @@ -899,7 +936,7 @@ class SHACLExtensibleObject(object): ) with decoder.read_property(key) as prop_d: - self._obj_data[key] = prop_d.read_value() + self.__dict__["_obj_data"][key] = prop_d.read_value() def _encode_properties(self, encoder, state): def encode_value(encoder, v): @@ -920,7 +957,7 @@ class SHACLExtensibleObject(object): if self.CLOSED: return - for iri, value in self._obj_data.items(): + for iri, value in self.__dict__["_obj_data"].items(): if iri in self._OBJ_PROPERTIES: continue @@ -936,7 +973,7 @@ class SHACLExtensibleObject(object): if not is_IRI(iri): raise KeyError(f"Key '{iri}' must be an IRI") - self._obj_data[iri] = value + self.__dict__["_obj_data"][iri] = value def __delitem__(self, iri): try: @@ -947,13 +984,13 @@ class SHACLExtensibleObject(object): if not is_IRI(iri): raise KeyError(f"Key '{iri}' must be an IRI") - del self._obj_data[iri] + del self.__dict__["_obj_data"][iri] def __getattr__(self, name): if name == "TYPE": - return self._obj_TYPE[0] + return self.__dict__["_obj_TYPE"][0] if name == "COMPACT_TYPE": - return self._obj_TYPE[1] + return self.__dict__["_obj_TYPE"][1] return super().__getattr__(name) def property_keys(self): @@ -965,7 +1002,7 @@ class SHACLExtensibleObject(object): if self.CLOSED: return - for iri in self._obj_data.keys(): + for iri in self.__dict__["_obj_data"].keys(): if iri not in iris: yield None, iri, None @@ -1073,6 +1110,8 @@ class SHACLObjectSet(object): return self._link() def _link(self): + global NAMED_INDIVIDUALS + self.missing_ids = set() visited = set() @@ -1095,6 +1134,9 @@ class SHACLObjectSet(object): obj_by_id[_id] = obj self.obj_by_id = obj_by_id + # Named individuals aren't considered missing + self.missing_ids -= NAMED_INDIVIDUALS + return self.missing_ids def find_by_id(self, _id, default=None): @@ -1487,14 +1529,14 @@ class JSONLDDecoder(Decoder): def object_keys(self): for key in self.data.keys(): - if key in ("@type", "{{ context.compact('@type') }}"): + if key in ("@type", "{{ context.compact_iri('@type') }}"): continue if self.root and key == "@context": continue yield key def read_object(self): - typ = self.__get_value("@type", "{{ context.compact('@type') }}") + typ = self.__get_value("@type", "{{ context.compact_iri('@type') }}") if typ is not None: return typ, self @@ -1686,7 +1728,7 @@ class JSONLDEncoder(Encoder): @contextmanager def write_object(self, o, _id, needs_id): self.data = { - "{{ context.compact('@type') }}": o.COMPACT_TYPE or o.TYPE, + "{{ context.compact_iri('@type') }}": o.COMPACT_TYPE or o.TYPE, } if needs_id: self.data[o.ID_ALIAS or "@id"] = _id @@ -1810,7 +1852,7 @@ class JSONLDInlineEncoder(Encoder): self._write_comma() self.write("{") - self.write_string("{{ context.compact('@type') }}") + self.write_string("{{ context.compact_iri('@type') }}") self.write(":") self.write_string(o.COMPACT_TYPE or o.TYPE) self.comma = True @@ -1940,9 +1982,7 @@ CONTEXT_URLS = [ #{{ (" " + l).rstrip() }} {%- endfor %} {%- endif %} -{%- if not class.is_abstract %} -@register("{{ class._id }}"{%- if context.compact(class._id) != class._id %}, "{{ context.compact(class._id) }}"{%- endif %}) -{%- endif %} +@register("{{ class._id }}"{%- if context.compact_iri(class._id) != class._id %}, compact_type="{{ context.compact_iri(class._id) }}"{%- endif %}, abstract={{ class.is_abstract }}) class {{ varname(*class.clsname) }}( {%- if class.is_extensible -%} SHACLExtensibleObject{{", "}} @@ -1971,13 +2011,6 @@ class {{ varname(*class.clsname) }}( {%- endif %} {{ varname(member.varname) }} = "{{ member._id }}" {%- endfor %} - {%- if class.is_abstract %} - - def __init__(self, *args, **kwargs): - if self.__class__ is {{ varname(*class.clsname) }}: - raise NotImplementedError(f"{self.__class__.__name__} is abstract and cannot be implemented") - super().__init__(*args, **kwargs) - {%- endif %} {%- if class.properties %} @classmethod @@ -2000,7 +2033,19 @@ class {{ varname(*class.clsname) }}( {%- endfor %} ]) {%- elif prop.class_id -%} - ObjectProp({{ varname(*classes.get(prop.class_id).clsname) }}, {% if prop.min_count and not is_list %}True{% else %}False{% endif %}) + {%- set ctx = [] %} + {%- for value in get_all_named_individuals(classes.get(prop.class_id)) %} + {%- if context.compact_vocab(value, prop.path) != value %} + {{- ctx.append((value, context.compact_vocab(value, prop.path))) or "" }} + {%- endif %} + {%- endfor -%} + ObjectProp({{ varname(*classes.get(prop.class_id).clsname) }}, {% if prop.min_count and not is_list %}True{% else %}False{% endif %}{% if ctx %}, context=[ + {%- for value, compact in ctx %} + ("{{ value }}", "{{ compact }}"), + {%- endfor %} + ], + {%- endif -%} + ) {%- else -%} {% if not prop.datatype in DATATYPE_CLASSES -%} {{ abort("Unknown data type " + prop.datatype) -}} @@ -2054,6 +2099,4 @@ def main(): if __name__ == "__main__": - import sys - sys.exit(main()) diff --git a/src/shacl2code/model.py b/src/shacl2code/model.py index f31ce99..22f05fe 100644 --- a/src/shacl2code/model.py +++ b/src/shacl2code/model.py @@ -280,7 +280,7 @@ def get_compact_id(self, _id, *, fallback=None): """ _id = str(_id) if _id not in self.compact_ids: - self.compact_ids[_id] = self.context.compact(_id) + self.compact_ids[_id] = self.context.compact_iri(_id) if self.compact_ids[_id] == _id and fallback is not None: return fallback diff --git a/src/shacl2code/version.py b/src/shacl2code/version.py index a1d5a2f..9bba779 100644 --- a/src/shacl2code/version.py +++ b/src/shacl2code/version.py @@ -1 +1 @@ -VERSION = "0.0.10" +VERSION = "0.0.13" diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py index b76561e..5af0785 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -9,8 +9,8 @@ import shutil import subprocess import time +from .http import HTTPTestServer -from pytest_server_fixtures.http import SimpleHTTPTestServer from pathlib import Path THIS_FILE = Path(__file__) @@ -21,19 +21,18 @@ @pytest.fixture def http_server(): - with SimpleHTTPTestServer() as s: + with HTTPTestServer() as s: s.start() yield s @pytest.fixture(scope="session") def model_server(): - with SimpleHTTPTestServer() as s: - root = Path(s.document_root) + with HTTPTestServer() as s: for p in MODEL_DIR.iterdir(): if not p.is_file(): continue - shutil.copyfile(p, root / p.name) + shutil.copyfile(p, s.document_root / p.name) s.start() yield s.uri diff --git a/tests/data/context.j2 b/tests/data/context.j2 index e4dd73f..ba74332 100644 --- a/tests/data/context.j2 +++ b/tests/data/context.j2 @@ -2,8 +2,8 @@ Context: {{ url }} {% endfor -%} {% for enum in enums %} -{{ enum._id }}: {{ context.compact(enum._id) }} +{{ enum._id }}: {{ context.compact_iri(enum._id) }} {% endfor %} {% for class in classes %} -{{ class._id }}: {{ context.compact(class._id) }} +{{ class._id }}: {{ context.compact_iri(class._id) }} {% endfor %} diff --git a/tests/data/model/test-context.json b/tests/data/model/test-context.json index bb0166b..e712ea2 100644 --- a/tests/data/model/test-context.json +++ b/tests/data/model/test-context.json @@ -3,6 +3,31 @@ "@base": "http://example.org/", "test": "http://example.org/", "xsd": "http://www.w3.org/2001/XMLSchema#", + "aaa-derived-class": "test:aaa-derived-class", + "abstract-class": "test:abstract-class", + "abstract-spdx-class": "test:abstract-spdx-class", + "concrete-class": "test:concrete-class", + "concrete-spdx-class": "test:concrete-spdx-class", + "derived-node-kind-iri": "test:derived-node-kind-iri", + "enumType": "test:enumType", + "extensible-abstract-class": "test:extensible-abstract-class", + "extensible-class": "test:extensible-class", + "id-prop-class": "test:id-prop-class", + "inherited-id-prop-class": "test:inherited-id-prop-class", + "link-class": "test:link-class", + "link-derived-class": "test:link-derived-class", + "node-kind-blank": "test:node-kind-blank", + "node-kind-iri": "test:node-kind-iri", + "node-kind-iri-or-blank": "test:node-kind-iri-or-blank", + "non-shape-class": "test:non-shape-class", + "parent-class": "test:parent-class", + "required-abstract": "test:required-abstract", + "test-another-class": "test:test-another-class", + "test-class": "test:test-class", + "test-class-required": "test:test-class-required", + "test-derived-class": "test:test-derived-class", + "uses-extensible-abstract-class": "test:uses-extensible-abstract-class", + "named": "test:test-class/named", "test-class/string-list-prop": { "@id": "test:test-class/string-list-prop", "@type": "xsd:string" @@ -65,7 +90,7 @@ }, "test-class/class-prop": { "@id": "test:test-class/class-prop", - "@type": "@id" + "@type": "@vocab" }, "test-class/class-prop-no-class": { "@id": "test:test-class/class-prop-no-class", @@ -77,15 +102,24 @@ }, "test-class/enum-prop": { "@id": "test:test-class/enum-prop", - "@type": "@id" + "@type": "@vocab", + "@context": { + "@vocab": "test:enumType/" + } }, "test-class/enum-list-prop": { "@id": "test:test-class/enum-list-prop", - "@type": "@id" + "@type": "@vocab", + "@context": { + "@vocab": "test:enumType/" + } }, "test-class/enum-prop-no-class": { "@id": "test:test-class/enum-prop-no-class", - "@type": "@id" + "@type": "@vocab", + "@context": { + "@vocab": "test:enumType/" + } }, "test-class/regex": { "@id": "test:test-class/regex", @@ -104,15 +138,15 @@ "@type": "xsd:dateTimeStamp" }, "link-class-link-prop": { - "@id": "test:link-class-prop", + "@id": "test:link-class-link-prop", "@type": "@id" }, "link-class-link-prop-no-class": { - "@id": "test:link-class-prop-no-class", + "@id": "test:link-class-link-prop-no-class", "@type": "@id" }, "link-class-link-list-prop": { - "@id": "test:link-class-list-prop", + "@id": "test:link-class-link-list-prop", "@type": "@id" }, "link-class-extensible": { @@ -130,6 +164,30 @@ "extensible-class/required": { "@id": "test:extensible-class/required", "@type": "xsd:string" + }, + "uses-extensible-abstract-class/prop": { + "@id": "test:uses-extensible-abstract-class/prop", + "@type": "@id" + }, + "import": { + "@id": "test:import", + "@type": "xsd:string" + }, + "encode": { + "@id": "test:encode", + "@type": "xsd:string" + }, + "test-class/non-shape": { + "@id": "test:test-class/non-shape", + "@type": "@id" + }, + "test-derived-class/string-prop": { + "@id": "test:test-derived-class/string-prop", + "@type": "xsd:string" + }, + "required-abstract/abstract-class-prop": { + "@id": "test:required-abstract/abstract-class-prop", + "@type": "@id" } } } diff --git a/tests/data/model/test.ttl b/tests/data/model/test.ttl index 0930e67..ea2531d 100644 --- a/tests/data/model/test.ttl +++ b/tests/data/model/test.ttl @@ -166,6 +166,10 @@ ] . + a owl:NamedIndividual, ; + rdfs:label "A named individual of the test class" + . + a sh:NodeShape, owl:Class ; rdfs:subClassOf ; sh:property [ @@ -385,7 +389,6 @@ sh:path ; ], [ - sh:class ; sh:path ; sh:maxCount 1 ] @@ -495,3 +498,38 @@ rdfs:subClassOf ; rdfs:comment "A concrete class" . + + a rdf:Class, sh:NodeShape, owl:Class ; + rdfs:comment "A class with a mandatory abstract class" ; + sh:property [ + sh:class ; + sh:path ; + sh:minCount 1 ; + sh:maxCount 1 + ] + . + + a rdf:Property ; + rdfs:comment "A required abstract class property" ; + rdfs:range + . + + a rdf:Class, sh:NodeShape, owl:Class ; + sh-to-code:isExtensible true ; + sh-to-code:isAbstract true ; + rdfs:comment "An extensible abstract class" + . + + a rdf:Class, sh:NodeShape, owl:Class ; + rdfs:comment "A class that uses an abstract extensible class" ; + sh:property [ + sh:path ; + sh:minCount 1 ; + sh:maxCount 1 + ] + . + + a rdf:Property ; + rdfs:comment "A property that references and abstract extensible class" ; + rdfs:range + . diff --git a/tests/data/python/roundtrip.json b/tests/data/python/roundtrip.json index 2d19121..414319a 100644 --- a/tests/data/python/roundtrip.json +++ b/tests/data/python/roundtrip.json @@ -10,6 +10,10 @@ "http://serialize.example.com/link-derived-target" ] }, + { + "@type": "concrete-class", + "@id": "http://serialize.example.com/concrete-class" + }, { "@id": "http://serialize.example.com/link-derived-target", "@type": "link-derived-class" @@ -58,8 +62,8 @@ "http://serialize.example.com/test-derived", "http://serialize.example.com/test" ], - "test-class/enum-prop": "enumType/foo", - "test-class/enum-prop-no-class": "enumType/bar", + "test-class/enum-prop": "foo", + "test-class/enum-prop-no-class": "bar", "test-class/regex": "foo1", "test-class/regex-datetime": "2024-03-11T00:00:00+01:00", "test-class/regex-datetimestamp": "2024-03-11T00:00:00Z", @@ -72,10 +76,22 @@ "@id": "http://serialize.example.com/test-derived", "@type": "test-derived-class" }, + { + "@type": "test-class", + "@id": "http://serialize.example.com/test-named-individual-reference", + "test-class/class-prop": "named" + }, { "@type": "test-class", "@id": "http://serialize.example.com/test-special-chars", "test-class/string-scalar-prop": "special chars \"\n\r:{}[]" + }, + { + "@type": "uses-extensible-abstract-class", + "@id": "http://serialize.example.com/test-uses-extensible-abstract", + "uses-extensible-abstract-class/prop": { + "@type": " http://serialize.example.com/custom-extensible" + } } ] } diff --git a/tests/expect/jsonschema/test-context.json b/tests/expect/jsonschema/test-context.json index 4113dc0..5060d83 100644 --- a/tests/expect/jsonschema/test-context.json +++ b/tests/expect/jsonschema/test-context.json @@ -178,6 +178,9 @@ { "$ref": "#/$defs/enumType" } ] }, + { "const": "test:enumType/foo" }, + { "const": "test:enumType/bar" }, + { "const": "test:enumType/nolabel" }, { "$ref": "#/$defs/BlankNodeOrIRI" } ] }, @@ -191,6 +194,45 @@ } ] }, + "extensibleabstractclass": { + "allOf": [ + { + "type": "object", + "unevaluatedProperties": true, + "properties": { + "@id": { "$ref": "#/$defs/BlankNodeOrIRI" }, + "@type": { + "allOf": [ + { "$ref": "#/$defs/IRI" }, + { "not": { "const": "extensible-abstract-class" } } + ] + } + } + }, + { "$ref": "#/$defs/extensibleabstractclass_props" } + ] + }, + "extensibleabstractclass_derived": { + "anyOf": [ + { + "type": "object", + "anyOf": [ + { "$ref": "#/$defs/extensibleabstractclass" } + ] + }, + { "$ref": "#/$defs/BlankNodeOrIRI" } + ] + }, + "extensibleabstractclass_props": { + "allOf": [ + { "$ref": "#/$defs/SHACLClass" }, + { + "type": "object", + "properties": { + } + } + ] + }, "idpropclass": { "allOf": [ { @@ -564,6 +606,7 @@ { "$ref": "#/$defs/parentclass" } ] }, + { "const": "named" }, { "$ref": "#/$defs/BlankNodeOrIRI" } ] }, @@ -577,6 +620,53 @@ } ] }, + "requiredabstract": { + "allOf": [ + { + "type": "object", + "properties": { + "@id": { "$ref": "#/$defs/BlankNodeOrIRI" }, + "@type": { + "oneOf": [ + { "const": "required-abstract" } + ] + } + } + }, + { "$ref": "#/$defs/requiredabstract_props" } + ] + }, + "requiredabstract_derived": { + "anyOf": [ + { + "type": "object", + "unevaluatedProperties": false, + "anyOf": [ + { "$ref": "#/$defs/requiredabstract" } + ] + }, + { "$ref": "#/$defs/BlankNodeOrIRI" } + ] + }, + "requiredabstract_props": { + "allOf": [ + { "$ref": "#/$defs/SHACLClass" }, + { + "type": "object", + "properties": { + "required-abstract/abstract-class-prop": { + "$ref": "#/$defs/prop_requiredabstract_requiredabstractabstractclassprop" + } + }, + "required": [ + "required-abstract/abstract-class-prop" + ] + } + ] + }, + "prop_requiredabstract_requiredabstractabstractclassprop": { + "$ref": "#/$defs/abstractclass_derived" + }, "testanotherclass": { "allOf": [ { @@ -642,6 +732,7 @@ { "$ref": "#/$defs/testclass" } ] }, + { "const": "named" }, { "$ref": "#/$defs/BlankNodeOrIRI" } ] }, @@ -822,26 +913,26 @@ }, "prop_testclass_testclassenumlistprop": { "enum": [ - "enumType/bar", - "enumType/foo", - "enumType/nolabel", - "enumType/non-named-individual" + "bar", + "foo", + "nolabel", + "non-named-individual" ] }, "prop_testclass_testclassenumprop": { "enum": [ - "enumType/bar", - "enumType/foo", - "enumType/nolabel", - "enumType/non-named-individual" + "bar", + "foo", + "nolabel", + "non-named-individual" ] }, "prop_testclass_testclassenumpropnoclass": { "enum": [ - "enumType/bar", - "enumType/foo", - "enumType/nolabel", - "enumType/non-named-individual" + "bar", + "foo", + "nolabel", + "non-named-individual" ] }, "prop_testclass_testclassfloatprop": { @@ -1018,6 +1109,53 @@ "prop_testderivedclass_testderivedclassstringprop": { "type": "string" }, + "usesextensibleabstractclass": { + "allOf": [ + { + "type": "object", + "properties": { + "@id": { "$ref": "#/$defs/BlankNodeOrIRI" }, + "@type": { + "oneOf": [ + { "const": "uses-extensible-abstract-class" } + ] + } + } + }, + { "$ref": "#/$defs/usesextensibleabstractclass_props" } + ] + }, + "usesextensibleabstractclass_derived": { + "anyOf": [ + { + "type": "object", + "unevaluatedProperties": false, + "anyOf": [ + { "$ref": "#/$defs/usesextensibleabstractclass" } + ] + }, + { "$ref": "#/$defs/BlankNodeOrIRI" } + ] + }, + "usesextensibleabstractclass_props": { + "allOf": [ + { "$ref": "#/$defs/SHACLClass" }, + { + "type": "object", + "properties": { + "uses-extensible-abstract-class/prop": { + "$ref": "#/$defs/prop_usesextensibleabstractclass_usesextensibleabstractclassprop" + } + }, + "required": [ + "uses-extensible-abstract-class/prop" + ] + } + ] + }, + "prop_usesextensibleabstractclass_usesextensibleabstractclassprop": { + "$ref": "#/$defs/extensibleabstractclass_derived" + }, "aaaderivedclass": { "allOf": [ { @@ -1166,18 +1304,6 @@ "anyURI": { "type": "string" }, - "DateTime": { - "type": "string" - }, - "MediaType": { - "type": "string" - }, - "SemVer": { - "type": "string" - }, - "Extension": { - "type": "string" - }, "SHACLClass": { "type": "object", "properties": { @@ -1198,10 +1324,12 @@ "node-kind-iri-or-blank", "non-shape-class", "parent-class", + "required-abstract", "test-another-class", "test-class", "test-class-required", "test-derived-class", + "uses-extensible-abstract-class", "aaa-derived-class", "derived-node-kind-iri", "extensible-class" @@ -1226,10 +1354,12 @@ { "$ref": "#/$defs/nodekindiriorblank" }, { "$ref": "#/$defs/nonshapeclass" }, { "$ref": "#/$defs/parentclass" }, + { "$ref": "#/$defs/requiredabstract" }, { "$ref": "#/$defs/testanotherclass" }, { "$ref": "#/$defs/testclass" }, { "$ref": "#/$defs/testclassrequired" }, { "$ref": "#/$defs/testderivedclass" }, + { "$ref": "#/$defs/usesextensibleabstractclass" }, { "$ref": "#/$defs/aaaderivedclass" }, { "$ref": "#/$defs/derivednodekindiri" }, { "$ref": "#/$defs/extensibleclass" } diff --git a/tests/expect/jsonschema/test.json b/tests/expect/jsonschema/test.json index 8e30ae9..d113e5d 100644 --- a/tests/expect/jsonschema/test.json +++ b/tests/expect/jsonschema/test.json @@ -187,6 +187,45 @@ } ] }, + "http_exampleorgextensibleabstractclass": { + "allOf": [ + { + "type": "object", + "unevaluatedProperties": true, + "properties": { + "@id": { "$ref": "#/$defs/BlankNodeOrIRI" }, + "@type": { + "allOf": [ + { "$ref": "#/$defs/IRI" }, + { "not": { "const": "http://example.org/extensible-abstract-class" } } + ] + } + } + }, + { "$ref": "#/$defs/http_exampleorgextensibleabstractclass_props" } + ] + }, + "http_exampleorgextensibleabstractclass_derived": { + "anyOf": [ + { + "type": "object", + "anyOf": [ + { "$ref": "#/$defs/http_exampleorgextensibleabstractclass" } + ] + }, + { "$ref": "#/$defs/BlankNodeOrIRI" } + ] + }, + "http_exampleorgextensibleabstractclass_props": { + "allOf": [ + { "$ref": "#/$defs/SHACLClass" }, + { + "type": "object", + "properties": { + } + } + ] + }, "http_exampleorgidpropclass": { "allOf": [ { @@ -573,6 +612,53 @@ } ] }, + "http_exampleorgrequiredabstract": { + "allOf": [ + { + "type": "object", + "properties": { + "@id": { "$ref": "#/$defs/BlankNodeOrIRI" }, + "@type": { + "oneOf": [ + { "const": "http://example.org/required-abstract" } + ] + } + } + }, + { "$ref": "#/$defs/http_exampleorgrequiredabstract_props" } + ] + }, + "http_exampleorgrequiredabstract_derived": { + "anyOf": [ + { + "type": "object", + "unevaluatedProperties": false, + "anyOf": [ + { "$ref": "#/$defs/http_exampleorgrequiredabstract" } + ] + }, + { "$ref": "#/$defs/BlankNodeOrIRI" } + ] + }, + "http_exampleorgrequiredabstract_props": { + "allOf": [ + { "$ref": "#/$defs/SHACLClass" }, + { + "type": "object", + "properties": { + "http://example.org/required-abstract/abstract-class-prop": { + "$ref": "#/$defs/prop_http_exampleorgrequiredabstract_abstractclassprop" + } + }, + "required": [ + "http://example.org/required-abstract/abstract-class-prop" + ] + } + ] + }, + "prop_http_exampleorgrequiredabstract_abstractclassprop": { + "$ref": "#/$defs/http_exampleorgabstractclass_derived" + }, "http_exampleorgtestanotherclass": { "allOf": [ { @@ -1014,6 +1100,53 @@ "prop_http_exampleorgtestderivedclass_stringprop": { "type": "string" }, + "http_exampleorgusesextensibleabstractclass": { + "allOf": [ + { + "type": "object", + "properties": { + "@id": { "$ref": "#/$defs/BlankNodeOrIRI" }, + "@type": { + "oneOf": [ + { "const": "http://example.org/uses-extensible-abstract-class" } + ] + } + } + }, + { "$ref": "#/$defs/http_exampleorgusesextensibleabstractclass_props" } + ] + }, + "http_exampleorgusesextensibleabstractclass_derived": { + "anyOf": [ + { + "type": "object", + "unevaluatedProperties": false, + "anyOf": [ + { "$ref": "#/$defs/http_exampleorgusesextensibleabstractclass" } + ] + }, + { "$ref": "#/$defs/BlankNodeOrIRI" } + ] + }, + "http_exampleorgusesextensibleabstractclass_props": { + "allOf": [ + { "$ref": "#/$defs/SHACLClass" }, + { + "type": "object", + "properties": { + "http://example.org/uses-extensible-abstract-class/prop": { + "$ref": "#/$defs/prop_http_exampleorgusesextensibleabstractclass_prop" + } + }, + "required": [ + "http://example.org/uses-extensible-abstract-class/prop" + ] + } + ] + }, + "prop_http_exampleorgusesextensibleabstractclass_prop": { + "$ref": "#/$defs/http_exampleorgextensibleabstractclass_derived" + }, "http_exampleorgaaaderivedclass": { "allOf": [ { @@ -1162,18 +1295,6 @@ "anyURI": { "type": "string" }, - "DateTime": { - "type": "string" - }, - "MediaType": { - "type": "string" - }, - "SemVer": { - "type": "string" - }, - "Extension": { - "type": "string" - }, "SHACLClass": { "type": "object", "properties": { @@ -1194,10 +1315,12 @@ "http://example.org/node-kind-iri-or-blank", "http://example.org/non-shape-class", "http://example.org/parent-class", + "http://example.org/required-abstract", "http://example.org/test-another-class", "http://example.org/test-class", "http://example.org/test-class-required", "http://example.org/test-derived-class", + "http://example.org/uses-extensible-abstract-class", "http://example.org/aaa-derived-class", "http://example.org/derived-node-kind-iri", "http://example.org/extensible-class" @@ -1222,10 +1345,12 @@ { "$ref": "#/$defs/http_exampleorgnodekindiriorblank" }, { "$ref": "#/$defs/http_exampleorgnonshapeclass" }, { "$ref": "#/$defs/http_exampleorgparentclass" }, + { "$ref": "#/$defs/http_exampleorgrequiredabstract" }, { "$ref": "#/$defs/http_exampleorgtestanotherclass" }, { "$ref": "#/$defs/http_exampleorgtestclass" }, { "$ref": "#/$defs/http_exampleorgtestclassrequired" }, { "$ref": "#/$defs/http_exampleorgtestderivedclass" }, + { "$ref": "#/$defs/http_exampleorgusesextensibleabstractclass" }, { "$ref": "#/$defs/http_exampleorgaaaderivedclass" }, { "$ref": "#/$defs/http_exampleorgderivednodekindiri" }, { "$ref": "#/$defs/http_exampleorgextensibleclass" } diff --git a/tests/expect/python/test-context.py b/tests/expect/python/test-context.py index 1ad47b2..361cbe3 100755 --- a/tests/expect/python/test-context.py +++ b/tests/expect/python/test-context.py @@ -10,8 +10,9 @@ import hashlib import json import re -import time +import sys import threading +import time from contextlib import contextmanager from datetime import datetime, timezone, timedelta from enum import Enum @@ -219,18 +220,39 @@ def decode(self, decoder, *, objectset=None): return decoder.read_float() -class ObjectProp(Property): +class IRIProp(Property): + def __init__(self, context=[], *, pattern=None): + super().__init__(pattern=pattern) + self.context = context + + def compact(self, value): + for iri, compact in self.context: + if value == iri: + return compact + return None + + def expand(self, value): + for iri, compact in self.context: + if value == compact: + return iri + return None + + def iri_values(self): + return (iri for iri, _ in self.context) + + +class ObjectProp(IRIProp): """ A scalar SHACL object property of a SHACL object """ - def __init__(self, cls, required): - super().__init__() + def __init__(self, cls, required, context=[]): + super().__init__(context) self.cls = cls self.required = required def init(self): - if self.required: + if self.required and not self.cls.IS_ABSTRACT: return self.cls() return None @@ -263,7 +285,7 @@ def encode(self, encoder, value, state): raise ValueError("Object cannot be None") if isinstance(value, str): - encoder.write_iri(value) + encoder.write_iri(value, self.compact(value)) return return value.encode(encoder, state) @@ -273,6 +295,8 @@ def decode(self, decoder, *, objectset=None): if iri is None: return self.cls.decode(decoder, objectset=objectset) + iri = self.expand(iri) or iri + if objectset is None: return iri @@ -441,36 +465,27 @@ def decode(self, decoder, *, objectset=None): return ListProxy(self.prop, data=data) -class EnumProp(Property): +class EnumProp(IRIProp): VALID_TYPES = str def __init__(self, values, *, pattern=None): - super().__init__(pattern=pattern) - self.values = values + super().__init__(values, pattern=pattern) def validate(self, value): super().validate(value) - valid_values = (iri for iri, _ in self.values) + valid_values = self.iri_values() if value not in valid_values: raise ValueError( f"'{value}' is not a valid value. Choose one of {' '.join(valid_values)}" ) def encode(self, encoder, value, state): - for iri, compact in self.values: - if iri == value: - encoder.write_enum(value, self, compact) - return - - encoder.write_enum(value, self) + encoder.write_enum(value, self, self.compact(value)) def decode(self, decoder, *, objectset=None): v = decoder.read_enum(self) - for iri, compact in self.values: - if v == compact: - return iri - return v + return self.expand(v) or v class NodeKind(Enum): @@ -497,7 +512,7 @@ def is_blank_node(s): return True -def register(type_iri, compact_type=None): +def register(type_iri, *, compact_type=None, abstract=False): def add_class(key, c): assert ( key not in SHACLObject.CLASSES @@ -505,17 +520,22 @@ def add_class(key, c): SHACLObject.CLASSES[key] = c def decorator(c): + global NAMED_INDIVIDUALS + assert issubclass( c, SHACLObject ), f"{c.__name__} is not derived from SHACLObject" c._OBJ_TYPE = type_iri + c.IS_ABSTRACT = abstract add_class(type_iri, c) c._OBJ_COMPACT_TYPE = compact_type if compact_type: add_class(compact_type, c) + NAMED_INDIVIDUALS |= set(c.NAMED_INDIVIDUALS.values()) + # Registration is deferred until the first instance of class is created # so that it has access to any other defined class c._NEEDS_REG = True @@ -525,6 +545,7 @@ def decorator(c): register_lock = threading.Lock() +NAMED_INDIVIDUALS = set() @functools.total_ordering @@ -532,8 +553,14 @@ class SHACLObject(object): CLASSES = {} NODE_KIND = NodeKind.BlankNodeOrIRI ID_ALIAS = None + IS_ABSTRACT = True def __init__(self, **kwargs): + if self._is_abstract(): + raise NotImplementedError( + f"{self.__class__.__name__} is abstract and cannot be implemented" + ) + with register_lock: cls = self.__class__ if cls._NEEDS_REG: @@ -542,15 +569,18 @@ def __init__(self, **kwargs): cls._register_props() cls._NEEDS_REG = False - self._obj_data = {} - self._obj_metadata = {} + self.__dict__["_obj_data"] = {} + self.__dict__["_obj_metadata"] = {} for iri, prop, _, _, _, _ in self.__iter_props(): - self._obj_data[iri] = prop.init() + self.__dict__["_obj_data"][iri] = prop.init() for k, v in kwargs.items(): setattr(self, k, v) + def _is_abstract(self): + return self.__class__.IS_ABSTRACT + @classmethod def _register_props(cls): cls._add_property("_id", StringProp(), iri="@id") @@ -573,15 +603,16 @@ def _add_property( while hasattr(cls, pyname): pyname = pyname + "_" + pyname = sys.intern(pyname) + iri = sys.intern(iri) + cls._OBJ_IRIS[pyname] = iri cls._OBJ_PROPERTIES[iri] = (prop, min_count, max_count, pyname, compact) def __setattr__(self, name, value): - if name.startswith("_obj_"): - return super().__setattr__(name, value) - if name == self.ID_ALIAS: - name = "_id" + self["@id"] = value + return try: iri = self._OBJ_IRIS[name] @@ -592,35 +623,32 @@ def __setattr__(self, name, value): ) def __getattr__(self, name): - if name.startswith("_obj_"): - return self.__dict__[name] + if name in self._OBJ_IRIS: + return self.__dict__["_obj_data"][self._OBJ_IRIS[name]] + + if name == self.ID_ALIAS: + return self.__dict__["_obj_data"]["@id"] if name == "_metadata": - return self._obj_metadata + return self.__dict__["_obj_metadata"] if name == "_IRI": return self._OBJ_IRIS - if name == self.ID_ALIAS: - name = "_id" - if name == "TYPE": return self.__class__._OBJ_TYPE if name == "COMPACT_TYPE": return self.__class__._OBJ_COMPACT_TYPE - try: - iri = self._OBJ_IRIS[name] - return self[iri] - except KeyError: - raise AttributeError( - f"'{name}' is not a valid property of {self.__class__.__name__}" - ) + raise AttributeError( + f"'{name}' is not a valid property of {self.__class__.__name__}" + ) def __delattr__(self, name): if name == self.ID_ALIAS: - name = "_id" + del self["@id"] + return try: iri = self._OBJ_IRIS[name] @@ -643,7 +671,7 @@ def __iter_props(self): yield iri, *v def __getitem__(self, iri): - return self._obj_data[iri] + return self.__dict__["_obj_data"][iri] def __setitem__(self, iri, value): if iri == "@id": @@ -665,11 +693,11 @@ def __setitem__(self, iri, value): prop, _, _, _, _ = self.__get_prop(iri) prop.validate(value) - self._obj_data[iri] = prop.set(value) + self.__dict__["_obj_data"][iri] = prop.set(value) def __delitem__(self, iri): prop, _, _, _, _ = self.__get_prop(iri) - self._obj_data[iri] = prop.init() + self.__dict__["_obj_data"][iri] = prop.init() def __iter__(self): return self._OBJ_PROPERTIES.keys() @@ -687,7 +715,7 @@ def callback(object, path): if callback(self, path): for iri, prop, _, _, _, _ in self.__iter_props(): - prop.walk(self._obj_data[iri], callback, path + [f".{iri}"]) + prop.walk(self.__dict__["_obj_data"][iri], callback, path + [f".{iri}"]) def property_keys(self): for iri, _, _, _, pyname, compact in self.__iter_props(): @@ -704,7 +732,7 @@ def iter_objects(self, *, recursive=False, visited=None): for iri, prop, _, _, _, _ in self.__iter_props(): for c in prop.iter_objects( - self._obj_data[iri], recursive=recursive, visited=visited + self.__dict__["_obj_data"][iri], recursive=recursive, visited=visited ): yield c @@ -730,7 +758,7 @@ def encode(self, encoder, state): def _encode_properties(self, encoder, state): for iri, prop, min_count, max_count, pyname, compact in self.__iter_props(): - value = self._obj_data[iri] + value = self.__dict__["_obj_data"][iri] if prop.elide(value): if min_count: raise ValueError( @@ -817,7 +845,7 @@ def _decode_prop(self, decoder, key, objectset=None): with decoder.read_property(read_key) as prop_d: v = prop.decode(prop_d, objectset=objectset) prop.validate(v) - self._obj_data[iri] = v + self.__dict__["_obj_data"][iri] = v return True return False @@ -829,8 +857,8 @@ def link_helper(self, objectset, missing, visited): visited.add(self) for iri, prop, _, _, _, _ in self.__iter_props(): - self._obj_data[iri] = prop.link_prop( - self._obj_data[iri], + self.__dict__["_obj_data"][iri] = prop.link_prop( + self.__dict__["_obj_data"][iri], objectset, missing, visited, @@ -869,11 +897,20 @@ class SHACLExtensibleObject(object): CLOSED = False def __init__(self, typ=None, **kwargs): - super().__init__(**kwargs) if typ: - self._obj_TYPE = (typ, None) + self.__dict__["_obj_TYPE"] = (typ, None) else: - self._obj_TYPE = (self._OBJ_TYPE, self._OBJ_COMPACT_TYPE) + self.__dict__["_obj_TYPE"] = (self._OBJ_TYPE, self._OBJ_COMPACT_TYPE) + super().__init__(**kwargs) + + def _is_abstract(self): + # Unknown classes are assumed to not be abstract so that they can be + # deserialized + typ = self.__dict__["_obj_TYPE"][0] + if typ in self.__class__.CLASSES: + return self.__class__.CLASSES[typ].IS_ABSTRACT + + return False @classmethod def _make_object(cls, typ): @@ -899,7 +936,7 @@ def _decode_properties(self, decoder, objectset=None): ) with decoder.read_property(key) as prop_d: - self._obj_data[key] = prop_d.read_value() + self.__dict__["_obj_data"][key] = prop_d.read_value() def _encode_properties(self, encoder, state): def encode_value(encoder, v): @@ -920,7 +957,7 @@ def encode_value(encoder, v): if self.CLOSED: return - for iri, value in self._obj_data.items(): + for iri, value in self.__dict__["_obj_data"].items(): if iri in self._OBJ_PROPERTIES: continue @@ -936,7 +973,7 @@ def __setitem__(self, iri, value): if not is_IRI(iri): raise KeyError(f"Key '{iri}' must be an IRI") - self._obj_data[iri] = value + self.__dict__["_obj_data"][iri] = value def __delitem__(self, iri): try: @@ -947,13 +984,13 @@ def __delitem__(self, iri): if not is_IRI(iri): raise KeyError(f"Key '{iri}' must be an IRI") - del self._obj_data[iri] + del self.__dict__["_obj_data"][iri] def __getattr__(self, name): if name == "TYPE": - return self._obj_TYPE[0] + return self.__dict__["_obj_TYPE"][0] if name == "COMPACT_TYPE": - return self._obj_TYPE[1] + return self.__dict__["_obj_TYPE"][1] return super().__getattr__(name) def property_keys(self): @@ -965,7 +1002,7 @@ def property_keys(self): if self.CLOSED: return - for iri in self._obj_data.keys(): + for iri in self.__dict__["_obj_data"].keys(): if iri not in iris: yield None, iri, None @@ -1073,6 +1110,8 @@ def link(self): return self._link() def _link(self): + global NAMED_INDIVIDUALS + self.missing_ids = set() visited = set() @@ -1095,6 +1134,9 @@ def _link(self): obj_by_id[_id] = obj self.obj_by_id = obj_by_id + # Named individuals aren't considered missing + self.missing_ids -= NAMED_INDIVIDUALS + return self.missing_ids def find_by_id(self, _id, default=None): @@ -1921,31 +1963,23 @@ def callback(value, path): # CLASSES # An Abstract class +@register("http://example.org/abstract-class", compact_type="abstract-class", abstract=True) class abstract_class(SHACLObject): NODE_KIND = NodeKind.BlankNodeOrIRI NAMED_INDIVIDUALS = { } - def __init__(self, *args, **kwargs): - if self.__class__ is abstract_class: - raise NotImplementedError(f"{self.__class__.__name__} is abstract and cannot be implemented") - super().__init__(*args, **kwargs) - # An Abstract class using the SPDX type +@register("http://example.org/abstract-spdx-class", compact_type="abstract-spdx-class", abstract=True) class abstract_spdx_class(SHACLObject): NODE_KIND = NodeKind.BlankNodeOrIRI NAMED_INDIVIDUALS = { } - def __init__(self, *args, **kwargs): - if self.__class__ is abstract_spdx_class: - raise NotImplementedError(f"{self.__class__.__name__} is abstract and cannot be implemented") - super().__init__(*args, **kwargs) - # A concrete class -@register("http://example.org/concrete-class", "concrete-class") +@register("http://example.org/concrete-class", compact_type="concrete-class", abstract=False) class concrete_class(abstract_class): NODE_KIND = NodeKind.BlankNodeOrIRI NAMED_INDIVIDUALS = { @@ -1953,7 +1987,7 @@ class concrete_class(abstract_class): # A concrete class -@register("http://example.org/concrete-spdx-class", "concrete-spdx-class") +@register("http://example.org/concrete-spdx-class", compact_type="concrete-spdx-class", abstract=False) class concrete_spdx_class(abstract_spdx_class): NODE_KIND = NodeKind.BlankNodeOrIRI NAMED_INDIVIDUALS = { @@ -1961,7 +1995,7 @@ class concrete_spdx_class(abstract_spdx_class): # An enumerated type -@register("http://example.org/enumType", "enumType") +@register("http://example.org/enumType", compact_type="enumType", abstract=False) class enumType(SHACLObject): NODE_KIND = NodeKind.BlankNodeOrIRI NAMED_INDIVIDUALS = { @@ -1977,8 +2011,16 @@ class enumType(SHACLObject): nolabel = "http://example.org/enumType/nolabel" +# An extensible abstract class +@register("http://example.org/extensible-abstract-class", compact_type="extensible-abstract-class", abstract=True) +class extensible_abstract_class(SHACLExtensibleObject, SHACLObject): + NODE_KIND = NodeKind.BlankNodeOrIRI + NAMED_INDIVIDUALS = { + } + + # A class with an ID alias -@register("http://example.org/id-prop-class", "id-prop-class") +@register("http://example.org/id-prop-class", compact_type="id-prop-class", abstract=False) class id_prop_class(SHACLObject): NODE_KIND = NodeKind.BlankNodeOrIRI ID_ALIAS = "testid" @@ -1987,7 +2029,7 @@ class id_prop_class(SHACLObject): # A class that inherits its idPropertyName from the parent -@register("http://example.org/inherited-id-prop-class", "inherited-id-prop-class") +@register("http://example.org/inherited-id-prop-class", compact_type="inherited-id-prop-class", abstract=False) class inherited_id_prop_class(id_prop_class): NODE_KIND = NodeKind.BlankNodeOrIRI ID_ALIAS = "testid" @@ -1996,7 +2038,7 @@ class inherited_id_prop_class(id_prop_class): # A class to test links -@register("http://example.org/link-class", "link-class") +@register("http://example.org/link-class", compact_type="link-class", abstract=False) class link_class(SHACLObject): NODE_KIND = NodeKind.BlankNodeOrIRI NAMED_INDIVIDUALS = { @@ -2036,7 +2078,7 @@ def _register_props(cls): # A class derived from link-class -@register("http://example.org/link-derived-class", "link-derived-class") +@register("http://example.org/link-derived-class", compact_type="link-derived-class", abstract=False) class link_derived_class(link_class): NODE_KIND = NodeKind.BlankNodeOrIRI NAMED_INDIVIDUALS = { @@ -2044,7 +2086,7 @@ class link_derived_class(link_class): # A class that must be a blank node -@register("http://example.org/node-kind-blank", "node-kind-blank") +@register("http://example.org/node-kind-blank", compact_type="node-kind-blank", abstract=False) class node_kind_blank(link_class): NODE_KIND = NodeKind.BlankNode NAMED_INDIVIDUALS = { @@ -2052,7 +2094,7 @@ class node_kind_blank(link_class): # A class that must be an IRI -@register("http://example.org/node-kind-iri", "node-kind-iri") +@register("http://example.org/node-kind-iri", compact_type="node-kind-iri", abstract=False) class node_kind_iri(link_class): NODE_KIND = NodeKind.IRI NAMED_INDIVIDUALS = { @@ -2060,7 +2102,7 @@ class node_kind_iri(link_class): # A class that can be either a blank node or an IRI -@register("http://example.org/node-kind-iri-or-blank", "node-kind-iri-or-blank") +@register("http://example.org/node-kind-iri-or-blank", compact_type="node-kind-iri-or-blank", abstract=False) class node_kind_iri_or_blank(link_class): NODE_KIND = NodeKind.BlankNodeOrIRI NAMED_INDIVIDUALS = { @@ -2068,7 +2110,7 @@ class node_kind_iri_or_blank(link_class): # A class that is not a nodeshape -@register("http://example.org/non-shape-class", "non-shape-class") +@register("http://example.org/non-shape-class", compact_type="non-shape-class", abstract=False) class non_shape_class(SHACLObject): NODE_KIND = NodeKind.BlankNodeOrIRI NAMED_INDIVIDUALS = { @@ -2076,15 +2118,35 @@ class non_shape_class(SHACLObject): # The parent class -@register("http://example.org/parent-class", "parent-class") +@register("http://example.org/parent-class", compact_type="parent-class", abstract=False) class parent_class(SHACLObject): NODE_KIND = NodeKind.BlankNodeOrIRI NAMED_INDIVIDUALS = { } +# A class with a mandatory abstract class +@register("http://example.org/required-abstract", compact_type="required-abstract", abstract=False) +class required_abstract(SHACLObject): + NODE_KIND = NodeKind.BlankNodeOrIRI + NAMED_INDIVIDUALS = { + } + + @classmethod + def _register_props(cls): + super()._register_props() + # A required abstract class property + cls._add_property( + "required_abstract_abstract_class_prop", + ObjectProp(abstract_class, True), + iri="http://example.org/required-abstract/abstract-class-prop", + min_count=1, + compact="required-abstract/abstract-class-prop", + ) + + # Another class -@register("http://example.org/test-another-class", "test-another-class") +@register("http://example.org/test-another-class", compact_type="test-another-class", abstract=False) class test_another_class(SHACLObject): NODE_KIND = NodeKind.BlankNodeOrIRI NAMED_INDIVIDUALS = { @@ -2092,11 +2154,13 @@ class test_another_class(SHACLObject): # The test class -@register("http://example.org/test-class", "test-class") +@register("http://example.org/test-class", compact_type="test-class", abstract=False) class test_class(parent_class): NODE_KIND = NodeKind.BlankNodeOrIRI NAMED_INDIVIDUALS = { + "named": "http://example.org/test-class/named", } + named = "http://example.org/test-class/named" @classmethod def _register_props(cls): @@ -2132,21 +2196,27 @@ def _register_props(cls): # A test-class list property cls._add_property( "test_class_class_list_prop", - ListProp(ObjectProp(test_class, False)), + ListProp(ObjectProp(test_class, False, context=[ + ("http://example.org/test-class/named", "named"), + ],)), iri="http://example.org/test-class/class-list-prop", compact="test-class/class-list-prop", ) # A test-class property cls._add_property( "test_class_class_prop", - ObjectProp(test_class, False), + ObjectProp(test_class, False, context=[ + ("http://example.org/test-class/named", "named"), + ],), iri="http://example.org/test-class/class-prop", compact="test-class/class-prop", ) # A test-class property with no sh:class cls._add_property( "test_class_class_prop_no_class", - ObjectProp(test_class, False), + ObjectProp(test_class, False, context=[ + ("http://example.org/test-class/named", "named"), + ],), iri="http://example.org/test-class/class-prop-no-class", compact="test-class/class-prop-no-class", ) @@ -2175,10 +2245,10 @@ def _register_props(cls): cls._add_property( "test_class_enum_list_prop", ListProp(EnumProp([ - ("http://example.org/enumType/bar", "enumType/bar"), - ("http://example.org/enumType/foo", "enumType/foo"), - ("http://example.org/enumType/nolabel", "enumType/nolabel"), - ("http://example.org/enumType/non-named-individual", "enumType/non-named-individual"), + ("http://example.org/enumType/bar", "bar"), + ("http://example.org/enumType/foo", "foo"), + ("http://example.org/enumType/nolabel", "nolabel"), + ("http://example.org/enumType/non-named-individual", "non-named-individual"), ])), iri="http://example.org/test-class/enum-list-prop", compact="test-class/enum-list-prop", @@ -2187,10 +2257,10 @@ def _register_props(cls): cls._add_property( "test_class_enum_prop", EnumProp([ - ("http://example.org/enumType/bar", "enumType/bar"), - ("http://example.org/enumType/foo", "enumType/foo"), - ("http://example.org/enumType/nolabel", "enumType/nolabel"), - ("http://example.org/enumType/non-named-individual", "enumType/non-named-individual"), + ("http://example.org/enumType/bar", "bar"), + ("http://example.org/enumType/foo", "foo"), + ("http://example.org/enumType/nolabel", "nolabel"), + ("http://example.org/enumType/non-named-individual", "non-named-individual"), ]), iri="http://example.org/test-class/enum-prop", compact="test-class/enum-prop", @@ -2199,10 +2269,10 @@ def _register_props(cls): cls._add_property( "test_class_enum_prop_no_class", EnumProp([ - ("http://example.org/enumType/bar", "enumType/bar"), - ("http://example.org/enumType/foo", "enumType/foo"), - ("http://example.org/enumType/nolabel", "enumType/nolabel"), - ("http://example.org/enumType/non-named-individual", "enumType/non-named-individual"), + ("http://example.org/enumType/bar", "bar"), + ("http://example.org/enumType/foo", "foo"), + ("http://example.org/enumType/nolabel", "nolabel"), + ("http://example.org/enumType/non-named-individual", "non-named-individual"), ]), iri="http://example.org/test-class/enum-prop-no-class", compact="test-class/enum-prop-no-class", @@ -2300,7 +2370,7 @@ def _register_props(cls): ) -@register("http://example.org/test-class-required", "test-class-required") +@register("http://example.org/test-class-required", compact_type="test-class-required", abstract=False) class test_class_required(test_class): NODE_KIND = NodeKind.BlankNodeOrIRI NAMED_INDIVIDUALS = { @@ -2329,7 +2399,7 @@ def _register_props(cls): # A class derived from test-class -@register("http://example.org/test-derived-class", "test-derived-class") +@register("http://example.org/test-derived-class", compact_type="test-derived-class", abstract=False) class test_derived_class(test_class): NODE_KIND = NodeKind.BlankNodeOrIRI NAMED_INDIVIDUALS = { @@ -2347,8 +2417,28 @@ def _register_props(cls): ) +# A class that uses an abstract extensible class +@register("http://example.org/uses-extensible-abstract-class", compact_type="uses-extensible-abstract-class", abstract=False) +class uses_extensible_abstract_class(SHACLObject): + NODE_KIND = NodeKind.BlankNodeOrIRI + NAMED_INDIVIDUALS = { + } + + @classmethod + def _register_props(cls): + super()._register_props() + # A property that references and abstract extensible class + cls._add_property( + "uses_extensible_abstract_class_prop", + ObjectProp(extensible_abstract_class, True), + iri="http://example.org/uses-extensible-abstract-class/prop", + min_count=1, + compact="uses-extensible-abstract-class/prop", + ) + + # Derived class that sorts before the parent to test ordering -@register("http://example.org/aaa-derived-class", "aaa-derived-class") +@register("http://example.org/aaa-derived-class", compact_type="aaa-derived-class", abstract=False) class aaa_derived_class(parent_class): NODE_KIND = NodeKind.BlankNodeOrIRI NAMED_INDIVIDUALS = { @@ -2356,7 +2446,7 @@ class aaa_derived_class(parent_class): # A class that derives its nodeKind from parent -@register("http://example.org/derived-node-kind-iri", "derived-node-kind-iri") +@register("http://example.org/derived-node-kind-iri", compact_type="derived-node-kind-iri", abstract=False) class derived_node_kind_iri(node_kind_iri): NODE_KIND = NodeKind.IRI NAMED_INDIVIDUALS = { @@ -2364,7 +2454,7 @@ class derived_node_kind_iri(node_kind_iri): # An extensible class -@register("http://example.org/extensible-class", "extensible-class") +@register("http://example.org/extensible-class", compact_type="extensible-class", abstract=False) class extensible_class(SHACLExtensibleObject, link_class): NODE_KIND = NodeKind.BlankNodeOrIRI NAMED_INDIVIDUALS = { @@ -2422,6 +2512,4 @@ def main(): if __name__ == "__main__": - import sys - sys.exit(main()) diff --git a/tests/expect/python/test.py b/tests/expect/python/test.py index 3e53c51..1242163 100644 --- a/tests/expect/python/test.py +++ b/tests/expect/python/test.py @@ -10,8 +10,9 @@ import hashlib import json import re -import time +import sys import threading +import time from contextlib import contextmanager from datetime import datetime, timezone, timedelta from enum import Enum @@ -219,18 +220,39 @@ def decode(self, decoder, *, objectset=None): return decoder.read_float() -class ObjectProp(Property): +class IRIProp(Property): + def __init__(self, context=[], *, pattern=None): + super().__init__(pattern=pattern) + self.context = context + + def compact(self, value): + for iri, compact in self.context: + if value == iri: + return compact + return None + + def expand(self, value): + for iri, compact in self.context: + if value == compact: + return iri + return None + + def iri_values(self): + return (iri for iri, _ in self.context) + + +class ObjectProp(IRIProp): """ A scalar SHACL object property of a SHACL object """ - def __init__(self, cls, required): - super().__init__() + def __init__(self, cls, required, context=[]): + super().__init__(context) self.cls = cls self.required = required def init(self): - if self.required: + if self.required and not self.cls.IS_ABSTRACT: return self.cls() return None @@ -263,7 +285,7 @@ def encode(self, encoder, value, state): raise ValueError("Object cannot be None") if isinstance(value, str): - encoder.write_iri(value) + encoder.write_iri(value, self.compact(value)) return return value.encode(encoder, state) @@ -273,6 +295,8 @@ def decode(self, decoder, *, objectset=None): if iri is None: return self.cls.decode(decoder, objectset=objectset) + iri = self.expand(iri) or iri + if objectset is None: return iri @@ -441,36 +465,27 @@ def decode(self, decoder, *, objectset=None): return ListProxy(self.prop, data=data) -class EnumProp(Property): +class EnumProp(IRIProp): VALID_TYPES = str def __init__(self, values, *, pattern=None): - super().__init__(pattern=pattern) - self.values = values + super().__init__(values, pattern=pattern) def validate(self, value): super().validate(value) - valid_values = (iri for iri, _ in self.values) + valid_values = self.iri_values() if value not in valid_values: raise ValueError( f"'{value}' is not a valid value. Choose one of {' '.join(valid_values)}" ) def encode(self, encoder, value, state): - for iri, compact in self.values: - if iri == value: - encoder.write_enum(value, self, compact) - return - - encoder.write_enum(value, self) + encoder.write_enum(value, self, self.compact(value)) def decode(self, decoder, *, objectset=None): v = decoder.read_enum(self) - for iri, compact in self.values: - if v == compact: - return iri - return v + return self.expand(v) or v class NodeKind(Enum): @@ -497,7 +512,7 @@ def is_blank_node(s): return True -def register(type_iri, compact_type=None): +def register(type_iri, *, compact_type=None, abstract=False): def add_class(key, c): assert ( key not in SHACLObject.CLASSES @@ -505,17 +520,22 @@ def add_class(key, c): SHACLObject.CLASSES[key] = c def decorator(c): + global NAMED_INDIVIDUALS + assert issubclass( c, SHACLObject ), f"{c.__name__} is not derived from SHACLObject" c._OBJ_TYPE = type_iri + c.IS_ABSTRACT = abstract add_class(type_iri, c) c._OBJ_COMPACT_TYPE = compact_type if compact_type: add_class(compact_type, c) + NAMED_INDIVIDUALS |= set(c.NAMED_INDIVIDUALS.values()) + # Registration is deferred until the first instance of class is created # so that it has access to any other defined class c._NEEDS_REG = True @@ -525,6 +545,7 @@ def decorator(c): register_lock = threading.Lock() +NAMED_INDIVIDUALS = set() @functools.total_ordering @@ -532,8 +553,14 @@ class SHACLObject(object): CLASSES = {} NODE_KIND = NodeKind.BlankNodeOrIRI ID_ALIAS = None + IS_ABSTRACT = True def __init__(self, **kwargs): + if self._is_abstract(): + raise NotImplementedError( + f"{self.__class__.__name__} is abstract and cannot be implemented" + ) + with register_lock: cls = self.__class__ if cls._NEEDS_REG: @@ -542,15 +569,18 @@ def __init__(self, **kwargs): cls._register_props() cls._NEEDS_REG = False - self._obj_data = {} - self._obj_metadata = {} + self.__dict__["_obj_data"] = {} + self.__dict__["_obj_metadata"] = {} for iri, prop, _, _, _, _ in self.__iter_props(): - self._obj_data[iri] = prop.init() + self.__dict__["_obj_data"][iri] = prop.init() for k, v in kwargs.items(): setattr(self, k, v) + def _is_abstract(self): + return self.__class__.IS_ABSTRACT + @classmethod def _register_props(cls): cls._add_property("_id", StringProp(), iri="@id") @@ -573,15 +603,16 @@ def _add_property( while hasattr(cls, pyname): pyname = pyname + "_" + pyname = sys.intern(pyname) + iri = sys.intern(iri) + cls._OBJ_IRIS[pyname] = iri cls._OBJ_PROPERTIES[iri] = (prop, min_count, max_count, pyname, compact) def __setattr__(self, name, value): - if name.startswith("_obj_"): - return super().__setattr__(name, value) - if name == self.ID_ALIAS: - name = "_id" + self["@id"] = value + return try: iri = self._OBJ_IRIS[name] @@ -592,35 +623,32 @@ def __setattr__(self, name, value): ) def __getattr__(self, name): - if name.startswith("_obj_"): - return self.__dict__[name] + if name in self._OBJ_IRIS: + return self.__dict__["_obj_data"][self._OBJ_IRIS[name]] + + if name == self.ID_ALIAS: + return self.__dict__["_obj_data"]["@id"] if name == "_metadata": - return self._obj_metadata + return self.__dict__["_obj_metadata"] if name == "_IRI": return self._OBJ_IRIS - if name == self.ID_ALIAS: - name = "_id" - if name == "TYPE": return self.__class__._OBJ_TYPE if name == "COMPACT_TYPE": return self.__class__._OBJ_COMPACT_TYPE - try: - iri = self._OBJ_IRIS[name] - return self[iri] - except KeyError: - raise AttributeError( - f"'{name}' is not a valid property of {self.__class__.__name__}" - ) + raise AttributeError( + f"'{name}' is not a valid property of {self.__class__.__name__}" + ) def __delattr__(self, name): if name == self.ID_ALIAS: - name = "_id" + del self["@id"] + return try: iri = self._OBJ_IRIS[name] @@ -643,7 +671,7 @@ def __iter_props(self): yield iri, *v def __getitem__(self, iri): - return self._obj_data[iri] + return self.__dict__["_obj_data"][iri] def __setitem__(self, iri, value): if iri == "@id": @@ -665,11 +693,11 @@ def __setitem__(self, iri, value): prop, _, _, _, _ = self.__get_prop(iri) prop.validate(value) - self._obj_data[iri] = prop.set(value) + self.__dict__["_obj_data"][iri] = prop.set(value) def __delitem__(self, iri): prop, _, _, _, _ = self.__get_prop(iri) - self._obj_data[iri] = prop.init() + self.__dict__["_obj_data"][iri] = prop.init() def __iter__(self): return self._OBJ_PROPERTIES.keys() @@ -687,7 +715,7 @@ def callback(object, path): if callback(self, path): for iri, prop, _, _, _, _ in self.__iter_props(): - prop.walk(self._obj_data[iri], callback, path + [f".{iri}"]) + prop.walk(self.__dict__["_obj_data"][iri], callback, path + [f".{iri}"]) def property_keys(self): for iri, _, _, _, pyname, compact in self.__iter_props(): @@ -704,7 +732,7 @@ def iter_objects(self, *, recursive=False, visited=None): for iri, prop, _, _, _, _ in self.__iter_props(): for c in prop.iter_objects( - self._obj_data[iri], recursive=recursive, visited=visited + self.__dict__["_obj_data"][iri], recursive=recursive, visited=visited ): yield c @@ -730,7 +758,7 @@ def encode(self, encoder, state): def _encode_properties(self, encoder, state): for iri, prop, min_count, max_count, pyname, compact in self.__iter_props(): - value = self._obj_data[iri] + value = self.__dict__["_obj_data"][iri] if prop.elide(value): if min_count: raise ValueError( @@ -817,7 +845,7 @@ def _decode_prop(self, decoder, key, objectset=None): with decoder.read_property(read_key) as prop_d: v = prop.decode(prop_d, objectset=objectset) prop.validate(v) - self._obj_data[iri] = v + self.__dict__["_obj_data"][iri] = v return True return False @@ -829,8 +857,8 @@ def link_helper(self, objectset, missing, visited): visited.add(self) for iri, prop, _, _, _, _ in self.__iter_props(): - self._obj_data[iri] = prop.link_prop( - self._obj_data[iri], + self.__dict__["_obj_data"][iri] = prop.link_prop( + self.__dict__["_obj_data"][iri], objectset, missing, visited, @@ -869,11 +897,20 @@ class SHACLExtensibleObject(object): CLOSED = False def __init__(self, typ=None, **kwargs): - super().__init__(**kwargs) if typ: - self._obj_TYPE = (typ, None) + self.__dict__["_obj_TYPE"] = (typ, None) else: - self._obj_TYPE = (self._OBJ_TYPE, self._OBJ_COMPACT_TYPE) + self.__dict__["_obj_TYPE"] = (self._OBJ_TYPE, self._OBJ_COMPACT_TYPE) + super().__init__(**kwargs) + + def _is_abstract(self): + # Unknown classes are assumed to not be abstract so that they can be + # deserialized + typ = self.__dict__["_obj_TYPE"][0] + if typ in self.__class__.CLASSES: + return self.__class__.CLASSES[typ].IS_ABSTRACT + + return False @classmethod def _make_object(cls, typ): @@ -899,7 +936,7 @@ def _decode_properties(self, decoder, objectset=None): ) with decoder.read_property(key) as prop_d: - self._obj_data[key] = prop_d.read_value() + self.__dict__["_obj_data"][key] = prop_d.read_value() def _encode_properties(self, encoder, state): def encode_value(encoder, v): @@ -920,7 +957,7 @@ def encode_value(encoder, v): if self.CLOSED: return - for iri, value in self._obj_data.items(): + for iri, value in self.__dict__["_obj_data"].items(): if iri in self._OBJ_PROPERTIES: continue @@ -936,7 +973,7 @@ def __setitem__(self, iri, value): if not is_IRI(iri): raise KeyError(f"Key '{iri}' must be an IRI") - self._obj_data[iri] = value + self.__dict__["_obj_data"][iri] = value def __delitem__(self, iri): try: @@ -947,13 +984,13 @@ def __delitem__(self, iri): if not is_IRI(iri): raise KeyError(f"Key '{iri}' must be an IRI") - del self._obj_data[iri] + del self.__dict__["_obj_data"][iri] def __getattr__(self, name): if name == "TYPE": - return self._obj_TYPE[0] + return self.__dict__["_obj_TYPE"][0] if name == "COMPACT_TYPE": - return self._obj_TYPE[1] + return self.__dict__["_obj_TYPE"][1] return super().__getattr__(name) def property_keys(self): @@ -965,7 +1002,7 @@ def property_keys(self): if self.CLOSED: return - for iri in self._obj_data.keys(): + for iri in self.__dict__["_obj_data"].keys(): if iri not in iris: yield None, iri, None @@ -1073,6 +1110,8 @@ def link(self): return self._link() def _link(self): + global NAMED_INDIVIDUALS + self.missing_ids = set() visited = set() @@ -1095,6 +1134,9 @@ def _link(self): obj_by_id[_id] = obj self.obj_by_id = obj_by_id + # Named individuals aren't considered missing + self.missing_ids -= NAMED_INDIVIDUALS + return self.missing_ids def find_by_id(self, _id, default=None): @@ -1920,31 +1962,23 @@ def callback(value, path): # CLASSES # An Abstract class +@register("http://example.org/abstract-class", abstract=True) class http_example_org_abstract_class(SHACLObject): NODE_KIND = NodeKind.BlankNodeOrIRI NAMED_INDIVIDUALS = { } - def __init__(self, *args, **kwargs): - if self.__class__ is http_example_org_abstract_class: - raise NotImplementedError(f"{self.__class__.__name__} is abstract and cannot be implemented") - super().__init__(*args, **kwargs) - # An Abstract class using the SPDX type +@register("http://example.org/abstract-spdx-class", abstract=True) class http_example_org_abstract_spdx_class(SHACLObject): NODE_KIND = NodeKind.BlankNodeOrIRI NAMED_INDIVIDUALS = { } - def __init__(self, *args, **kwargs): - if self.__class__ is http_example_org_abstract_spdx_class: - raise NotImplementedError(f"{self.__class__.__name__} is abstract and cannot be implemented") - super().__init__(*args, **kwargs) - # A concrete class -@register("http://example.org/concrete-class") +@register("http://example.org/concrete-class", abstract=False) class http_example_org_concrete_class(http_example_org_abstract_class): NODE_KIND = NodeKind.BlankNodeOrIRI NAMED_INDIVIDUALS = { @@ -1952,7 +1986,7 @@ class http_example_org_concrete_class(http_example_org_abstract_class): # A concrete class -@register("http://example.org/concrete-spdx-class") +@register("http://example.org/concrete-spdx-class", abstract=False) class http_example_org_concrete_spdx_class(http_example_org_abstract_spdx_class): NODE_KIND = NodeKind.BlankNodeOrIRI NAMED_INDIVIDUALS = { @@ -1960,7 +1994,7 @@ class http_example_org_concrete_spdx_class(http_example_org_abstract_spdx_class) # An enumerated type -@register("http://example.org/enumType") +@register("http://example.org/enumType", abstract=False) class http_example_org_enumType(SHACLObject): NODE_KIND = NodeKind.BlankNodeOrIRI NAMED_INDIVIDUALS = { @@ -1976,8 +2010,16 @@ class http_example_org_enumType(SHACLObject): nolabel = "http://example.org/enumType/nolabel" +# An extensible abstract class +@register("http://example.org/extensible-abstract-class", abstract=True) +class http_example_org_extensible_abstract_class(SHACLExtensibleObject, SHACLObject): + NODE_KIND = NodeKind.BlankNodeOrIRI + NAMED_INDIVIDUALS = { + } + + # A class with an ID alias -@register("http://example.org/id-prop-class") +@register("http://example.org/id-prop-class", abstract=False) class http_example_org_id_prop_class(SHACLObject): NODE_KIND = NodeKind.BlankNodeOrIRI ID_ALIAS = "testid" @@ -1986,7 +2028,7 @@ class http_example_org_id_prop_class(SHACLObject): # A class that inherits its idPropertyName from the parent -@register("http://example.org/inherited-id-prop-class") +@register("http://example.org/inherited-id-prop-class", abstract=False) class http_example_org_inherited_id_prop_class(http_example_org_id_prop_class): NODE_KIND = NodeKind.BlankNodeOrIRI ID_ALIAS = "testid" @@ -1995,7 +2037,7 @@ class http_example_org_inherited_id_prop_class(http_example_org_id_prop_class): # A class to test links -@register("http://example.org/link-class") +@register("http://example.org/link-class", abstract=False) class http_example_org_link_class(SHACLObject): NODE_KIND = NodeKind.BlankNodeOrIRI NAMED_INDIVIDUALS = { @@ -2031,7 +2073,7 @@ def _register_props(cls): # A class derived from link-class -@register("http://example.org/link-derived-class") +@register("http://example.org/link-derived-class", abstract=False) class http_example_org_link_derived_class(http_example_org_link_class): NODE_KIND = NodeKind.BlankNodeOrIRI NAMED_INDIVIDUALS = { @@ -2039,7 +2081,7 @@ class http_example_org_link_derived_class(http_example_org_link_class): # A class that must be a blank node -@register("http://example.org/node-kind-blank") +@register("http://example.org/node-kind-blank", abstract=False) class http_example_org_node_kind_blank(http_example_org_link_class): NODE_KIND = NodeKind.BlankNode NAMED_INDIVIDUALS = { @@ -2047,7 +2089,7 @@ class http_example_org_node_kind_blank(http_example_org_link_class): # A class that must be an IRI -@register("http://example.org/node-kind-iri") +@register("http://example.org/node-kind-iri", abstract=False) class http_example_org_node_kind_iri(http_example_org_link_class): NODE_KIND = NodeKind.IRI NAMED_INDIVIDUALS = { @@ -2055,7 +2097,7 @@ class http_example_org_node_kind_iri(http_example_org_link_class): # A class that can be either a blank node or an IRI -@register("http://example.org/node-kind-iri-or-blank") +@register("http://example.org/node-kind-iri-or-blank", abstract=False) class http_example_org_node_kind_iri_or_blank(http_example_org_link_class): NODE_KIND = NodeKind.BlankNodeOrIRI NAMED_INDIVIDUALS = { @@ -2063,7 +2105,7 @@ class http_example_org_node_kind_iri_or_blank(http_example_org_link_class): # A class that is not a nodeshape -@register("http://example.org/non-shape-class") +@register("http://example.org/non-shape-class", abstract=False) class http_example_org_non_shape_class(SHACLObject): NODE_KIND = NodeKind.BlankNodeOrIRI NAMED_INDIVIDUALS = { @@ -2071,15 +2113,34 @@ class http_example_org_non_shape_class(SHACLObject): # The parent class -@register("http://example.org/parent-class") +@register("http://example.org/parent-class", abstract=False) class http_example_org_parent_class(SHACLObject): NODE_KIND = NodeKind.BlankNodeOrIRI NAMED_INDIVIDUALS = { } +# A class with a mandatory abstract class +@register("http://example.org/required-abstract", abstract=False) +class http_example_org_required_abstract(SHACLObject): + NODE_KIND = NodeKind.BlankNodeOrIRI + NAMED_INDIVIDUALS = { + } + + @classmethod + def _register_props(cls): + super()._register_props() + # A required abstract class property + cls._add_property( + "abstract_class_prop", + ObjectProp(http_example_org_abstract_class, True), + iri="http://example.org/required-abstract/abstract-class-prop", + min_count=1, + ) + + # Another class -@register("http://example.org/test-another-class") +@register("http://example.org/test-another-class", abstract=False) class http_example_org_test_another_class(SHACLObject): NODE_KIND = NodeKind.BlankNodeOrIRI NAMED_INDIVIDUALS = { @@ -2087,11 +2148,13 @@ class http_example_org_test_another_class(SHACLObject): # The test class -@register("http://example.org/test-class") +@register("http://example.org/test-class", abstract=False) class http_example_org_test_class(http_example_org_parent_class): NODE_KIND = NodeKind.BlankNodeOrIRI NAMED_INDIVIDUALS = { + "named": "http://example.org/test-class/named", } + named = "http://example.org/test-class/named" @classmethod def _register_props(cls): @@ -2269,7 +2332,7 @@ def _register_props(cls): ) -@register("http://example.org/test-class-required") +@register("http://example.org/test-class-required", abstract=False) class http_example_org_test_class_required(http_example_org_test_class): NODE_KIND = NodeKind.BlankNodeOrIRI NAMED_INDIVIDUALS = { @@ -2296,7 +2359,7 @@ def _register_props(cls): # A class derived from test-class -@register("http://example.org/test-derived-class") +@register("http://example.org/test-derived-class", abstract=False) class http_example_org_test_derived_class(http_example_org_test_class): NODE_KIND = NodeKind.BlankNodeOrIRI NAMED_INDIVIDUALS = { @@ -2313,8 +2376,27 @@ def _register_props(cls): ) +# A class that uses an abstract extensible class +@register("http://example.org/uses-extensible-abstract-class", abstract=False) +class http_example_org_uses_extensible_abstract_class(SHACLObject): + NODE_KIND = NodeKind.BlankNodeOrIRI + NAMED_INDIVIDUALS = { + } + + @classmethod + def _register_props(cls): + super()._register_props() + # A property that references and abstract extensible class + cls._add_property( + "prop", + ObjectProp(http_example_org_extensible_abstract_class, True), + iri="http://example.org/uses-extensible-abstract-class/prop", + min_count=1, + ) + + # Derived class that sorts before the parent to test ordering -@register("http://example.org/aaa-derived-class") +@register("http://example.org/aaa-derived-class", abstract=False) class http_example_org_aaa_derived_class(http_example_org_parent_class): NODE_KIND = NodeKind.BlankNodeOrIRI NAMED_INDIVIDUALS = { @@ -2322,7 +2404,7 @@ class http_example_org_aaa_derived_class(http_example_org_parent_class): # A class that derives its nodeKind from parent -@register("http://example.org/derived-node-kind-iri") +@register("http://example.org/derived-node-kind-iri", abstract=False) class http_example_org_derived_node_kind_iri(http_example_org_node_kind_iri): NODE_KIND = NodeKind.IRI NAMED_INDIVIDUALS = { @@ -2330,7 +2412,7 @@ class http_example_org_derived_node_kind_iri(http_example_org_node_kind_iri): # An extensible class -@register("http://example.org/extensible-class") +@register("http://example.org/extensible-class", abstract=False) class http_example_org_extensible_class(SHACLExtensibleObject, http_example_org_link_class): NODE_KIND = NodeKind.BlankNodeOrIRI NAMED_INDIVIDUALS = { @@ -2386,6 +2468,4 @@ def main(): if __name__ == "__main__": - import sys - sys.exit(main()) diff --git a/tests/expect/raw/test-context.txt b/tests/expect/raw/test-context.txt index e9e7a54..752b91f 100644 --- a/tests/expect/raw/test-context.txt +++ b/tests/expect/raw/test-context.txt @@ -11,6 +11,8 @@ http://example.org/concrete-spdx-class: concrete-spdx-class http://example.org/enumType: enumType +http://example.org/extensible-abstract-class: extensible-abstract-class + http://example.org/id-prop-class: id-prop-class http://example.org/inherited-id-prop-class: inherited-id-prop-class @@ -29,6 +31,8 @@ http://example.org/non-shape-class: non-shape-class http://example.org/parent-class: parent-class +http://example.org/required-abstract: required-abstract + http://example.org/test-another-class: test-another-class http://example.org/test-class: test-class @@ -37,6 +41,8 @@ http://example.org/test-class-required: test-class-required http://example.org/test-derived-class: test-derived-class +http://example.org/uses-extensible-abstract-class: uses-extensible-abstract-class + http://example.org/aaa-derived-class: aaa-derived-class http://example.org/derived-node-kind-iri: derived-node-kind-iri diff --git a/tests/expect/raw/test.txt b/tests/expect/raw/test.txt index c454f18..d248cab 100644 --- a/tests/expect/raw/test.txt +++ b/tests/expect/raw/test.txt @@ -5,6 +5,7 @@ Class(_id='http://example.org/abstract-spdx-class', clsname=['http', '//example. Class(_id='http://example.org/concrete-class', clsname=['http', '//example.org/concrete-class'], parent_ids=['http://example.org/abstract-class'], derived_ids=[], properties=[], comment='A concrete class', id_property=None, node_kind=rdflib.term.URIRef('http://www.w3.org/ns/shacl#BlankNodeOrIRI'), is_extensible=False, is_abstract=False, named_individuals=[]) Class(_id='http://example.org/concrete-spdx-class', clsname=['http', '//example.org/concrete-spdx-class'], parent_ids=['http://example.org/abstract-spdx-class'], derived_ids=[], properties=[], comment='A concrete class', id_property=None, node_kind=rdflib.term.URIRef('http://www.w3.org/ns/shacl#BlankNodeOrIRI'), is_extensible=False, is_abstract=False, named_individuals=[]) Class(_id='http://example.org/enumType', clsname=['http', '//example.org/enumType'], parent_ids=[], derived_ids=[], properties=[], comment='An enumerated type', id_property=None, node_kind=rdflib.term.URIRef('http://www.w3.org/ns/shacl#BlankNodeOrIRI'), is_extensible=False, is_abstract=False, named_individuals=[Individual(_id='http://example.org/enumType/foo', varname='foo', comment='The foo value of enumType'), Individual(_id='http://example.org/enumType/bar', varname='bar', comment='The bar value of enumType'), Individual(_id='http://example.org/enumType/nolabel', varname='nolabel', comment='This value has no label')]) +Class(_id='http://example.org/extensible-abstract-class', clsname=['http', '//example.org/extensible-abstract-class'], parent_ids=[], derived_ids=[], properties=[], comment='An extensible abstract class', id_property=None, node_kind=rdflib.term.URIRef('http://www.w3.org/ns/shacl#BlankNodeOrIRI'), is_extensible=True, is_abstract=True, named_individuals=[]) Class(_id='http://example.org/id-prop-class', clsname=['http', '//example.org/id-prop-class'], parent_ids=[], derived_ids=['http://example.org/inherited-id-prop-class'], properties=[], comment='A class with an ID alias', id_property='testid', node_kind=rdflib.term.URIRef('http://www.w3.org/ns/shacl#BlankNodeOrIRI'), is_extensible=False, is_abstract=False, named_individuals=[]) Class(_id='http://example.org/inherited-id-prop-class', clsname=['http', '//example.org/inherited-id-prop-class'], parent_ids=['http://example.org/id-prop-class'], derived_ids=[], properties=[], comment='A class that inherits its idPropertyName from the parent', id_property='testid', node_kind=rdflib.term.URIRef('http://www.w3.org/ns/shacl#BlankNodeOrIRI'), is_extensible=False, is_abstract=False, named_individuals=[]) Class(_id='http://example.org/link-class', clsname=['http', '//example.org/link-class'], parent_ids=[], derived_ids=['http://example.org/extensible-class', 'http://example.org/link-derived-class', 'http://example.org/node-kind-blank', 'http://example.org/node-kind-iri', 'http://example.org/node-kind-iri-or-blank'], properties=[Property(path='http://example.org/link-class-extensible', varname='-extensible', comment='A link to an extensible-class', max_count=1, min_count=None, enum_values=None, class_id='http://example.org/extensible-class', datatype='', pattern=''), Property(path='http://example.org/link-class-link-list-prop', varname='-link-list-prop', comment='A link-class list property', max_count=None, min_count=None, enum_values=None, class_id='http://example.org/link-class', datatype='', pattern=''), Property(path='http://example.org/link-class-link-prop', varname='-link-prop', comment='A link-class property', max_count=1, min_count=None, enum_values=None, class_id='http://example.org/link-class', datatype='', pattern=''), Property(path='http://example.org/link-class-link-prop-no-class', varname='-link-prop-no-class', comment='A link-class property with no sh:class', max_count=1, min_count=None, enum_values=None, class_id='http://example.org/link-class', datatype='', pattern='')], comment='A class to test links', id_property=None, node_kind=rdflib.term.URIRef('http://www.w3.org/ns/shacl#BlankNodeOrIRI'), is_extensible=False, is_abstract=False, named_individuals=[]) @@ -14,10 +15,12 @@ Class(_id='http://example.org/node-kind-iri', clsname=['http', '//example.org/no Class(_id='http://example.org/node-kind-iri-or-blank', clsname=['http', '//example.org/node-kind-iri-or-blank'], parent_ids=['http://example.org/link-class'], derived_ids=[], properties=[], comment='A class that can be either a blank node or an IRI', id_property=None, node_kind=rdflib.term.URIRef('http://www.w3.org/ns/shacl#BlankNodeOrIRI'), is_extensible=False, is_abstract=False, named_individuals=[]) Class(_id='http://example.org/non-shape-class', clsname=['http', '//example.org/non-shape-class'], parent_ids=[], derived_ids=[], properties=[], comment='A class that is not a nodeshape', id_property=None, node_kind=rdflib.term.URIRef('http://www.w3.org/ns/shacl#BlankNodeOrIRI'), is_extensible=False, is_abstract=False, named_individuals=[]) Class(_id='http://example.org/parent-class', clsname=['http', '//example.org/parent-class'], parent_ids=[], derived_ids=['http://example.org/aaa-derived-class', 'http://example.org/test-class'], properties=[], comment='The parent class', id_property=None, node_kind=rdflib.term.URIRef('http://www.w3.org/ns/shacl#BlankNodeOrIRI'), is_extensible=False, is_abstract=False, named_individuals=[]) +Class(_id='http://example.org/required-abstract', clsname=['http', '//example.org/required-abstract'], parent_ids=[], derived_ids=[], properties=[Property(path='http://example.org/required-abstract/abstract-class-prop', varname='abstract-class-prop', comment='A required abstract class property', max_count=1, min_count=1, enum_values=None, class_id='http://example.org/abstract-class', datatype='', pattern='')], comment='A class with a mandatory abstract class', id_property=None, node_kind=rdflib.term.URIRef('http://www.w3.org/ns/shacl#BlankNodeOrIRI'), is_extensible=False, is_abstract=False, named_individuals=[]) Class(_id='http://example.org/test-another-class', clsname=['http', '//example.org/test-another-class'], parent_ids=[], derived_ids=[], properties=[], comment='Another class', id_property=None, node_kind=rdflib.term.URIRef('http://www.w3.org/ns/shacl#BlankNodeOrIRI'), is_extensible=False, is_abstract=False, named_individuals=[]) -Class(_id='http://example.org/test-class', clsname=['http', '//example.org/test-class'], parent_ids=['http://example.org/parent-class'], derived_ids=['http://example.org/test-class-required', 'http://example.org/test-derived-class'], properties=[Property(path='http://example.org/encode', varname='encode', comment='A property that conflicts with an existing SHACLObject property', max_count=1, min_count=None, enum_values=None, class_id='', datatype='http://www.w3.org/2001/XMLSchema#string', pattern=''), Property(path='http://example.org/import', varname='import', comment='A property that is a keyword', max_count=1, min_count=None, enum_values=None, class_id='', datatype='http://www.w3.org/2001/XMLSchema#string', pattern=''), Property(path='http://example.org/test-class/anyuri-prop', varname='anyuri-prop', comment='a URI', max_count=1, min_count=None, enum_values=None, class_id='', datatype='http://www.w3.org/2001/XMLSchema#anyURI', pattern=''), Property(path='http://example.org/test-class/boolean-prop', varname='boolean-prop', comment='a boolean property', max_count=1, min_count=None, enum_values=None, class_id='', datatype='http://www.w3.org/2001/XMLSchema#boolean', pattern=''), Property(path='http://example.org/test-class/class-list-prop', varname='class-list-prop', comment='A test-class list property', max_count=None, min_count=None, enum_values=None, class_id='http://example.org/test-class', datatype='', pattern=''), Property(path='http://example.org/test-class/class-prop', varname='class-prop', comment='A test-class property', max_count=1, min_count=None, enum_values=None, class_id='http://example.org/test-class', datatype='', pattern=''), Property(path='http://example.org/test-class/class-prop-no-class', varname='class-prop-no-class', comment='A test-class property with no sh:class', max_count=1, min_count=None, enum_values=None, class_id='http://example.org/test-class', datatype='', pattern=''), Property(path='http://example.org/test-class/datetime-list-prop', varname='datetime-list-prop', comment='A datetime list property', max_count=None, min_count=None, enum_values=None, class_id='', datatype='http://www.w3.org/2001/XMLSchema#dateTime', pattern=''), Property(path='http://example.org/test-class/datetime-scalar-prop', varname='datetime-scalar-prop', comment='A scalar datetime property', max_count=1, min_count=None, enum_values=None, class_id='', datatype='http://www.w3.org/2001/XMLSchema#dateTime', pattern=''), Property(path='http://example.org/test-class/datetimestamp-scalar-prop', varname='datetimestamp-scalar-prop', comment='A scalar dateTimeStamp property', max_count=1, min_count=None, enum_values=None, class_id='', datatype='http://www.w3.org/2001/XMLSchema#dateTimeStamp', pattern=''), Property(path='http://example.org/test-class/enum-list-prop', varname='enum-list-prop', comment='A enum list property', max_count=None, min_count=None, enum_values=[rdflib.term.URIRef('http://example.org/enumType/bar'), rdflib.term.URIRef('http://example.org/enumType/foo'), rdflib.term.URIRef('http://example.org/enumType/nolabel'), rdflib.term.URIRef('http://example.org/enumType/non-named-individual')], class_id='http://example.org/enumType', datatype='', pattern=''), Property(path='http://example.org/test-class/enum-prop', varname='enum-prop', comment='A enum property', max_count=1, min_count=None, enum_values=[rdflib.term.URIRef('http://example.org/enumType/bar'), rdflib.term.URIRef('http://example.org/enumType/foo'), rdflib.term.URIRef('http://example.org/enumType/nolabel'), rdflib.term.URIRef('http://example.org/enumType/non-named-individual')], class_id='http://example.org/enumType', datatype='', pattern=''), Property(path='http://example.org/test-class/enum-prop-no-class', varname='enum-prop-no-class', comment='A enum property with no sh:class', max_count=1, min_count=None, enum_values=[rdflib.term.URIRef('http://example.org/enumType/bar'), rdflib.term.URIRef('http://example.org/enumType/foo'), rdflib.term.URIRef('http://example.org/enumType/nolabel'), rdflib.term.URIRef('http://example.org/enumType/non-named-individual')], class_id='http://example.org/enumType', datatype='', pattern=''), Property(path='http://example.org/test-class/float-prop', varname='float-prop', comment='a float property', max_count=1, min_count=None, enum_values=None, class_id='', datatype='http://www.w3.org/2001/XMLSchema#decimal', pattern=''), Property(path='http://example.org/test-class/integer-prop', varname='integer-prop', comment='a non-negative integer', max_count=1, min_count=None, enum_values=None, class_id='', datatype='http://www.w3.org/2001/XMLSchema#integer', pattern=''), Property(path='http://example.org/test-class/named-property', varname=rdflib.term.Literal('named_property'), comment='A named property', max_count=1, min_count=None, enum_values=None, class_id='', datatype='http://www.w3.org/2001/XMLSchema#string', pattern=''), Property(path='http://example.org/test-class/non-shape', varname='non-shape', comment='A class with no shape', max_count=1, min_count=None, enum_values=None, class_id='http://example.org/non-shape-class', datatype='', pattern=''), Property(path='http://example.org/test-class/nonnegative-integer-prop', varname='nonnegative-integer-prop', comment='a non-negative integer', max_count=1, min_count=None, enum_values=None, class_id='', datatype='http://www.w3.org/2001/XMLSchema#nonNegativeInteger', pattern=''), Property(path='http://example.org/test-class/positive-integer-prop', varname='positive-integer-prop', comment='A positive integer', max_count=1, min_count=None, enum_values=None, class_id='', datatype='http://www.w3.org/2001/XMLSchema#positiveInteger', pattern=''), Property(path='http://example.org/test-class/regex', varname='regex', comment='A regex validated string', max_count=1, min_count=None, enum_values=None, class_id='', datatype='http://www.w3.org/2001/XMLSchema#string', pattern='^foo\\d'), Property(path='http://example.org/test-class/regex-datetime', varname='regex-datetime', comment='A regex dateTime', max_count=1, min_count=None, enum_values=None, class_id='', datatype='http://www.w3.org/2001/XMLSchema#dateTime', pattern='^\\d\\d\\d\\d-\\d\\d-\\d\\dT\\d\\d:\\d\\d:\\d\\d\\+01:00$'), Property(path='http://example.org/test-class/regex-datetimestamp', varname='regex-datetimestamp', comment='A regex dateTimeStamp', max_count=1, min_count=None, enum_values=None, class_id='', datatype='http://www.w3.org/2001/XMLSchema#dateTimeStamp', pattern='^\\d\\d\\d\\d-\\d\\d-\\d\\dT\\d\\d:\\d\\d:\\d\\dZ$'), Property(path='http://example.org/test-class/regex-list', varname='regex-list', comment='A regex validated string list', max_count=None, min_count=None, enum_values=None, class_id='', datatype='http://www.w3.org/2001/XMLSchema#string', pattern='^foo\\d'), Property(path='http://example.org/test-class/string-list-no-datatype', varname='string-list-no-datatype', comment='A string list property with no sh:datatype', max_count=None, min_count=None, enum_values=None, class_id='', datatype='http://www.w3.org/2001/XMLSchema#string', pattern=''), Property(path='http://example.org/test-class/string-list-prop', varname='string-list-prop', comment='A string list property', max_count=None, min_count=None, enum_values=None, class_id='', datatype='http://www.w3.org/2001/XMLSchema#string', pattern=''), Property(path='http://example.org/test-class/string-scalar-prop', varname='string-scalar-prop', comment='A scalar string propery', max_count=1, min_count=None, enum_values=None, class_id='', datatype='http://www.w3.org/2001/XMLSchema#string', pattern='')], comment='The test class', id_property=None, node_kind=rdflib.term.URIRef('http://www.w3.org/ns/shacl#BlankNodeOrIRI'), is_extensible=False, is_abstract=False, named_individuals=[]) +Class(_id='http://example.org/test-class', clsname=['http', '//example.org/test-class'], parent_ids=['http://example.org/parent-class'], derived_ids=['http://example.org/test-class-required', 'http://example.org/test-derived-class'], properties=[Property(path='http://example.org/encode', varname='encode', comment='A property that conflicts with an existing SHACLObject property', max_count=1, min_count=None, enum_values=None, class_id='', datatype='http://www.w3.org/2001/XMLSchema#string', pattern=''), Property(path='http://example.org/import', varname='import', comment='A property that is a keyword', max_count=1, min_count=None, enum_values=None, class_id='', datatype='http://www.w3.org/2001/XMLSchema#string', pattern=''), Property(path='http://example.org/test-class/anyuri-prop', varname='anyuri-prop', comment='a URI', max_count=1, min_count=None, enum_values=None, class_id='', datatype='http://www.w3.org/2001/XMLSchema#anyURI', pattern=''), Property(path='http://example.org/test-class/boolean-prop', varname='boolean-prop', comment='a boolean property', max_count=1, min_count=None, enum_values=None, class_id='', datatype='http://www.w3.org/2001/XMLSchema#boolean', pattern=''), Property(path='http://example.org/test-class/class-list-prop', varname='class-list-prop', comment='A test-class list property', max_count=None, min_count=None, enum_values=None, class_id='http://example.org/test-class', datatype='', pattern=''), Property(path='http://example.org/test-class/class-prop', varname='class-prop', comment='A test-class property', max_count=1, min_count=None, enum_values=None, class_id='http://example.org/test-class', datatype='', pattern=''), Property(path='http://example.org/test-class/class-prop-no-class', varname='class-prop-no-class', comment='A test-class property with no sh:class', max_count=1, min_count=None, enum_values=None, class_id='http://example.org/test-class', datatype='', pattern=''), Property(path='http://example.org/test-class/datetime-list-prop', varname='datetime-list-prop', comment='A datetime list property', max_count=None, min_count=None, enum_values=None, class_id='', datatype='http://www.w3.org/2001/XMLSchema#dateTime', pattern=''), Property(path='http://example.org/test-class/datetime-scalar-prop', varname='datetime-scalar-prop', comment='A scalar datetime property', max_count=1, min_count=None, enum_values=None, class_id='', datatype='http://www.w3.org/2001/XMLSchema#dateTime', pattern=''), Property(path='http://example.org/test-class/datetimestamp-scalar-prop', varname='datetimestamp-scalar-prop', comment='A scalar dateTimeStamp property', max_count=1, min_count=None, enum_values=None, class_id='', datatype='http://www.w3.org/2001/XMLSchema#dateTimeStamp', pattern=''), Property(path='http://example.org/test-class/enum-list-prop', varname='enum-list-prop', comment='A enum list property', max_count=None, min_count=None, enum_values=[rdflib.term.URIRef('http://example.org/enumType/bar'), rdflib.term.URIRef('http://example.org/enumType/foo'), rdflib.term.URIRef('http://example.org/enumType/nolabel'), rdflib.term.URIRef('http://example.org/enumType/non-named-individual')], class_id='http://example.org/enumType', datatype='', pattern=''), Property(path='http://example.org/test-class/enum-prop', varname='enum-prop', comment='A enum property', max_count=1, min_count=None, enum_values=[rdflib.term.URIRef('http://example.org/enumType/bar'), rdflib.term.URIRef('http://example.org/enumType/foo'), rdflib.term.URIRef('http://example.org/enumType/nolabel'), rdflib.term.URIRef('http://example.org/enumType/non-named-individual')], class_id='http://example.org/enumType', datatype='', pattern=''), Property(path='http://example.org/test-class/enum-prop-no-class', varname='enum-prop-no-class', comment='A enum property with no sh:class', max_count=1, min_count=None, enum_values=[rdflib.term.URIRef('http://example.org/enumType/bar'), rdflib.term.URIRef('http://example.org/enumType/foo'), rdflib.term.URIRef('http://example.org/enumType/nolabel'), rdflib.term.URIRef('http://example.org/enumType/non-named-individual')], class_id='http://example.org/enumType', datatype='', pattern=''), Property(path='http://example.org/test-class/float-prop', varname='float-prop', comment='a float property', max_count=1, min_count=None, enum_values=None, class_id='', datatype='http://www.w3.org/2001/XMLSchema#decimal', pattern=''), Property(path='http://example.org/test-class/integer-prop', varname='integer-prop', comment='a non-negative integer', max_count=1, min_count=None, enum_values=None, class_id='', datatype='http://www.w3.org/2001/XMLSchema#integer', pattern=''), Property(path='http://example.org/test-class/named-property', varname=rdflib.term.Literal('named_property'), comment='A named property', max_count=1, min_count=None, enum_values=None, class_id='', datatype='http://www.w3.org/2001/XMLSchema#string', pattern=''), Property(path='http://example.org/test-class/non-shape', varname='non-shape', comment='A class with no shape', max_count=1, min_count=None, enum_values=None, class_id='http://example.org/non-shape-class', datatype='', pattern=''), Property(path='http://example.org/test-class/nonnegative-integer-prop', varname='nonnegative-integer-prop', comment='a non-negative integer', max_count=1, min_count=None, enum_values=None, class_id='', datatype='http://www.w3.org/2001/XMLSchema#nonNegativeInteger', pattern=''), Property(path='http://example.org/test-class/positive-integer-prop', varname='positive-integer-prop', comment='A positive integer', max_count=1, min_count=None, enum_values=None, class_id='', datatype='http://www.w3.org/2001/XMLSchema#positiveInteger', pattern=''), Property(path='http://example.org/test-class/regex', varname='regex', comment='A regex validated string', max_count=1, min_count=None, enum_values=None, class_id='', datatype='http://www.w3.org/2001/XMLSchema#string', pattern='^foo\\d'), Property(path='http://example.org/test-class/regex-datetime', varname='regex-datetime', comment='A regex dateTime', max_count=1, min_count=None, enum_values=None, class_id='', datatype='http://www.w3.org/2001/XMLSchema#dateTime', pattern='^\\d\\d\\d\\d-\\d\\d-\\d\\dT\\d\\d:\\d\\d:\\d\\d\\+01:00$'), Property(path='http://example.org/test-class/regex-datetimestamp', varname='regex-datetimestamp', comment='A regex dateTimeStamp', max_count=1, min_count=None, enum_values=None, class_id='', datatype='http://www.w3.org/2001/XMLSchema#dateTimeStamp', pattern='^\\d\\d\\d\\d-\\d\\d-\\d\\dT\\d\\d:\\d\\d:\\d\\dZ$'), Property(path='http://example.org/test-class/regex-list', varname='regex-list', comment='A regex validated string list', max_count=None, min_count=None, enum_values=None, class_id='', datatype='http://www.w3.org/2001/XMLSchema#string', pattern='^foo\\d'), Property(path='http://example.org/test-class/string-list-no-datatype', varname='string-list-no-datatype', comment='A string list property with no sh:datatype', max_count=None, min_count=None, enum_values=None, class_id='', datatype='http://www.w3.org/2001/XMLSchema#string', pattern=''), Property(path='http://example.org/test-class/string-list-prop', varname='string-list-prop', comment='A string list property', max_count=None, min_count=None, enum_values=None, class_id='', datatype='http://www.w3.org/2001/XMLSchema#string', pattern=''), Property(path='http://example.org/test-class/string-scalar-prop', varname='string-scalar-prop', comment='A scalar string propery', max_count=1, min_count=None, enum_values=None, class_id='', datatype='http://www.w3.org/2001/XMLSchema#string', pattern='')], comment='The test class', id_property=None, node_kind=rdflib.term.URIRef('http://www.w3.org/ns/shacl#BlankNodeOrIRI'), is_extensible=False, is_abstract=False, named_individuals=[Individual(_id='http://example.org/test-class/named', varname='named', comment='')]) Class(_id='http://example.org/test-class-required', clsname=['http', '//example.org/test-class-required'], parent_ids=['http://example.org/test-class'], derived_ids=[], properties=[Property(path='http://example.org/test-class/required-string-list-prop', varname='required-string-list-prop', comment='A required string list property', max_count=2, min_count=1, enum_values=None, class_id='', datatype='http://www.w3.org/2001/XMLSchema#string', pattern=''), Property(path='http://example.org/test-class/required-string-scalar-prop', varname='required-string-scalar-prop', comment='A required scalar string property', max_count=1, min_count=1, enum_values=None, class_id='', datatype='http://www.w3.org/2001/XMLSchema#string', pattern='')], comment='', id_property=None, node_kind=rdflib.term.URIRef('http://www.w3.org/ns/shacl#BlankNodeOrIRI'), is_extensible=False, is_abstract=False, named_individuals=[]) Class(_id='http://example.org/test-derived-class', clsname=['http', '//example.org/test-derived-class'], parent_ids=['http://example.org/test-class'], derived_ids=[], properties=[Property(path='http://example.org/test-derived-class/string-prop', varname='string-prop', comment='A string property in a derived class', max_count=1, min_count=None, enum_values=None, class_id='', datatype='http://www.w3.org/2001/XMLSchema#string', pattern='')], comment='A class derived from test-class', id_property=None, node_kind=rdflib.term.URIRef('http://www.w3.org/ns/shacl#BlankNodeOrIRI'), is_extensible=False, is_abstract=False, named_individuals=[]) +Class(_id='http://example.org/uses-extensible-abstract-class', clsname=['http', '//example.org/uses-extensible-abstract-class'], parent_ids=[], derived_ids=[], properties=[Property(path='http://example.org/uses-extensible-abstract-class/prop', varname='prop', comment='A property that references and abstract extensible class', max_count=1, min_count=1, enum_values=None, class_id='http://example.org/extensible-abstract-class', datatype='', pattern='')], comment='A class that uses an abstract extensible class', id_property=None, node_kind=rdflib.term.URIRef('http://www.w3.org/ns/shacl#BlankNodeOrIRI'), is_extensible=False, is_abstract=False, named_individuals=[]) Class(_id='http://example.org/aaa-derived-class', clsname=['http', '//example.org/aaa-derived-class'], parent_ids=['http://example.org/parent-class'], derived_ids=[], properties=[], comment='Derived class that sorts before the parent to test ordering', id_property=None, node_kind=rdflib.term.URIRef('http://www.w3.org/ns/shacl#BlankNodeOrIRI'), is_extensible=False, is_abstract=False, named_individuals=[]) Class(_id='http://example.org/derived-node-kind-iri', clsname=['http', '//example.org/derived-node-kind-iri'], parent_ids=['http://example.org/node-kind-iri'], derived_ids=[], properties=[], comment='A class that derives its nodeKind from parent', id_property=None, node_kind=rdflib.term.URIRef('http://www.w3.org/ns/shacl#IRI'), is_extensible=False, is_abstract=False, named_individuals=[]) Class(_id='http://example.org/extensible-class', clsname=['http', '//example.org/extensible-class'], parent_ids=['http://example.org/link-class'], derived_ids=[], properties=[Property(path='http://example.org/extensible-class/property', varname='property', comment='An extensible property', max_count=1, min_count=None, enum_values=None, class_id='', datatype='http://www.w3.org/2001/XMLSchema#string', pattern=''), Property(path='http://example.org/extensible-class/required', varname='required', comment='A required extensible property', max_count=1, min_count=1, enum_values=None, class_id='', datatype='http://www.w3.org/2001/XMLSchema#string', pattern='')], comment='An extensible class', id_property=None, node_kind=rdflib.term.URIRef('http://www.w3.org/ns/shacl#BlankNodeOrIRI'), is_extensible=True, is_abstract=False, named_individuals=[]) diff --git a/tests/http.py b/tests/http.py new file mode 100644 index 0000000..25d932c --- /dev/null +++ b/tests/http.py @@ -0,0 +1,74 @@ +# +# Copyright (c) 2024 Joshua Watt +# +# SPDX-License-Identifier: MIT + +import socket +import subprocess +import sys +import tempfile +import time + +from pathlib import Path +from contextlib import closing + + +def get_ephemeral_port(host): + with closing(socket.socket(socket.AF_INET, socket.SOCK_STREAM)) as s: + s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + s.bind((host, 0)) + return s.getsockname()[1] + + +class HTTPTestServer(object): + def __init__(self): + self.p = None + self.temp_dir = None + + def start(self): + assert self.p is None, "Server already started" + + self.host = "127.0.0.1" + self.port = get_ephemeral_port(self.host) + self.p = subprocess.Popen( + [sys.executable, "-m", "http.server", "--bind", self.host, str(self.port)], + cwd=self.document_root, + ) + self.uri = f"http://{self.host}:{self.port}" + + # Wait for server to start + start_time = time.monotonic() + while time.monotonic() < start_time + 30: + assert self.p.poll() is None + with closing(socket.socket(socket.AF_INET, socket.SOCK_STREAM)) as s: + try: + s.connect((self.host, self.port)) + return + + except ConnectionRefusedError: + continue + + # Timeout + self.p.terminate() + self.p.wait() + assert False, "Timeout waiting for server to be ready" + + def stop(self): + if self.p is None: + return + + self.p.terminate() + self.p.wait() + + def __enter__(self): + self.temp_dir = tempfile.TemporaryDirectory() + return self + + def __exit__(self, typ, value, tb): + self.stop() + self.temp_dir.cleanup() + self.temp_dir = None + + @property + def document_root(self): + return Path(self.temp_dir.name) diff --git a/tests/test_context.py b/tests/test_context.py index 08e62ff..599d7e0 100644 --- a/tests/test_context.py +++ b/tests/test_context.py @@ -4,141 +4,133 @@ # SPDX-License-Identifier: MIT import pytest +import json +import subprocess from shacl2code.context import Context TEST_CONTEXTS = [ { - "foo": "http://bar/", - "foobat": "foo:bat", - "idfoo": { - "@id": "http://idbar/", + "root": "http://root/", + "rootPrefix1": "root:prefix1/", + "rootPrefix2": "rootPrefix1:prefix2/", + "rootTerminal1": "root:terminal", + "rootTerminal2": "rootTerminal1:terminal2", + "idProperty": { + "@id": "root:property", "@type": "@id", }, - "idfoobat": { - "@id": "idfoo:bat", - "@type": "@id", - }, - "v": { + "vocabProperty": { "@type": "@vocab", - "@id": "foo:vocab", + "@id": "root:vocab", "@context": { - "@vocab": "foo:prefix/", + "@vocab": "root:vocabPrefix/", }, }, - "idfoostring": { - "@id": "idfoo:string", - "@type": "http://www.w3.org/2001/XMLSchema#string", + "rootVocabProperty": { + "@type": "@vocab", + "@id": "root:rootVocab", }, - }, + "named": "root:named", + } ] BASE_CONTEXT = [ { - "@base": "http://bar/", + "@base": "http://base/", }, ] @pytest.mark.parametrize( - "context,compact_id,expand_id", - [ - (TEST_CONTEXTS, "nonexist", "nonexist"), - (TEST_CONTEXTS, "foo", "http://bar/"), - (TEST_CONTEXTS, "foo:baz", "http://bar/baz"), - (TEST_CONTEXTS, "foobat", "http://bar/bat"), - (TEST_CONTEXTS, "idfoo", "http://idbar/"), - (TEST_CONTEXTS, "idfoobat", "http://idbar/bat"), - (TEST_CONTEXTS, "foo:prefix/value", "http://bar/prefix/value"), - (TEST_CONTEXTS, "value", "value"), - (TEST_CONTEXTS, "v", "http://bar/vocab"), - (TEST_CONTEXTS, "idfoostring", "http://idbar/string"), - (BASE_CONTEXT, "foo", "http://bar/foo"), - (BASE_CONTEXT, "_:foo", "_:foo"), - (BASE_CONTEXT, ":foo", "http://bar/:foo"), - (BASE_CONTEXT, "http:foo", "http:foo"), - (BASE_CONTEXT, ":", "http://bar/:"), - (BASE_CONTEXT, "http://foo/bar", "http://foo/bar"), - ], -) -def test_expand_compact(context, compact_id, expand_id): - ctx = Context(context) - - # Test expansion - assert ctx.expand(compact_id) == expand_id - - # Test compaction - assert ctx.compact(expand_id) == compact_id - - -@pytest.mark.parametrize( - "context,compact_id,expand_id,expand_vocab,vocab", - [ - ( - TEST_CONTEXTS, - "value", - "value", - "http://bar/prefix/value", - "http://bar/vocab", - ), - ( - TEST_CONTEXTS, - "http://foo/bar", - "http://foo/bar", - "http://bar/prefix/http://foo/bar", - "http://bar/vocab", - ), - ], -) -def test_expand_compact_vocab(context, compact_id, expand_id, expand_vocab, vocab): - ctx = Context(context) - - # Test expansion - assert ctx.expand(compact_id) == expand_id - - # Test compaction - assert ctx.compact(expand_id) == compact_id - - # Test vocab expansion - assert ctx.expand_vocab(compact_id, vocab) == expand_vocab - - # Test vocab push - with ctx.vocab_push(vocab): - assert ctx.expand_vocab(compact_id) == expand_vocab - assert ctx.compact_vocab(expand_vocab) == compact_id - # Pushed vocab should not affect regular expansion - assert ctx.expand(compact_id) == expand_id - - assert ctx.expand_vocab(compact_id, vocab) == expand_vocab - assert ctx.compact_vocab(expand_vocab, vocab) == compact_id - - # Vocab with no pushed or specified context is equivalent to base - assert ctx.expand_vocab(compact_id) == expand_id - assert ctx.compact_vocab(expand_id) == compact_id - - -@pytest.mark.parametrize( - "context,compact_id,expand_id,expand_vocab,vocab", + "extra_contexts,compact", [ - (BASE_CONTEXT, "http://bar/foo", "http://bar/foo", None, None), + ([], "nonexist"), + ([], "root"), + ([], "rootPrefix1"), + ([], "rootPrefix2"), + # Test a "Hidden" prefix where the prefix itself doesn't have a + # trailing "/", but it aliases a prefix which does + ([{"h": "rootPrefix2"}], "h:a"), + ([], "rootPrefix2:test"), + ([], "rootPrefix2:test/suffix"), + ([], "rootTerminal1"), + ([], "rootTerminal1:suffix"), + ([], "rootTerminal2"), + ([], "rootTerminal2:suffix"), + ([], "idProperty"), + ([], "vocabProperty"), + ([], "named"), + ([], "named:suffix"), + ([], "named/suffix"), + ([], "_:blank"), + ([], "http:url"), ], ) -def test_expand(context, compact_id, expand_id, expand_vocab, vocab): - """ - This tests expansion edge cases without checking if the compaction will - reverse back - """ - ctx = Context(context) - if expand_vocab is None: - expand_vocab = expand_id - - # Test expansion - assert ctx.expand(compact_id) == expand_id - - # Test vocab expansion - assert ctx.expand_vocab(compact_id, vocab) == expand_vocab - - # Test vocab push - with ctx.vocab_push(vocab): - assert ctx.expand_vocab(compact_id) == expand_vocab - # Pushed vocab should not affect regular expansion - assert ctx.expand(compact_id) == expand_id +def test_expand_compact(extra_contexts, compact): + def test_context(contexts, compact): + ctx = Context(TEST_CONTEXTS + contexts) + root_vocab = ctx.expand_iri("rootVocabProperty") + vocab = ctx.expand_iri("vocabProperty") + + data = { + "@context": TEST_CONTEXTS + contexts, + "@id": compact, + "_:key": { + "@id": "_:id", + compact: "foo", + }, + "_:value": compact, + "rootVocabProperty": compact, + "vocabProperty": compact, + } + + p = subprocess.run( + ["npm", "exec", "--", "jsonld-cli", "expand", "-"], + input=json.dumps(data), + stdout=subprocess.PIPE, + encoding="utf-8", + check=True, + ) + result = json.loads(p.stdout)[0] + + expand_id = result["@id"] + for k in result["_:key"][0].keys(): + if k == "@id": + continue + expand_iri = k + break + else: + expand_iri = compact + + expand_root_vocab = result["http://root/rootVocab"][0]["@id"] + expand_vocab = result["http://root/vocab"][0]["@id"] + + assert result["_:value"][0]["@value"] == compact + + def check(actual, expected, desc): + if actual != expected: + print(json.dumps(data, indent=2)) + assert False, f"{desc} failed: {actual!r} != {expected!r}" + + check(ctx.expand_iri(compact), expand_iri, "Expand IRI") + check(ctx.expand_id(compact), expand_id, "Expand ID") + check(ctx.expand_vocab(compact, vocab), expand_vocab, "Expand vocab") + check(ctx.expand_vocab(compact, None), expand_root_vocab, "Expand no vocab") + check( + ctx.expand_vocab(compact, root_vocab), + expand_root_vocab, + "Expand root vocab", + ) + + check(ctx.compact_iri(expand_iri), compact, "Compact IRI") + check(ctx.compact_id(expand_id), compact, "Compact ID") + check(ctx.compact_vocab(expand_vocab, vocab), compact, "Compact vocab") + check(ctx.compact_vocab(expand_root_vocab, None), compact, "Compact no vocab") + check( + ctx.compact_vocab(expand_root_vocab, root_vocab), + compact, + "Compact root vocab", + ) + + test_context(extra_contexts, compact) + test_context(BASE_CONTEXT + extra_contexts, compact) diff --git a/tests/test_jsonschema.py b/tests/test_jsonschema.py index 44d4e66..af85006 100644 --- a/tests/test_jsonschema.py +++ b/tests/test_jsonschema.py @@ -701,6 +701,38 @@ def node_kind_tests(name, blank, iri): "@type": "concrete-spdx-class", }, ), + # An extensible abstract class cannot be instantiated + ( + False, + { + "@context": CONTEXT, + "@type": "extensible-abstract-class", + }, + ), + # Any can type can be used where a extensible abstract class is + # references, except... (SEE NEXT) + ( + True, + { + "@context": CONTEXT, + "@type": "uses-extensible-abstract-class", + "uses-extensible-abstract-class/prop": { + "@type": "http://example.com/extended", + }, + }, + ), + # ... the exact type of the extensible abstract class is specifically + # not allowed + ( + False, + { + "@context": CONTEXT, + "@type": "uses-extensible-abstract-class", + "uses-extensible-abstract-class/prop": { + "@type": "extensible-abstract-class", + }, + }, + ), # Base object for type tests (True, BASE_OBJ), ], @@ -804,8 +836,8 @@ def lst(v): *type_tests("test-class/named-property", good=["foo", ""], typ=[str]), *type_tests( "test-class/enum-prop", - good=["enumType/foo"], - bad=["foo"], + good=["foo"], + bad=["enumType/foo"], typ=[str], ), *type_tests( @@ -815,10 +847,14 @@ def lst(v): {"@type": "test-derived-class"}, "_:blanknode", "http://serialize.example.org/test", + # Named individual + "named", ], bad=[ {"@type": "test-another-class"}, {"@type": "parent-class"}, + "not/an/iri", + "not-an-iri", ], typ=[object, str], ), @@ -880,9 +916,9 @@ def lst(v): "test-class/enum-list-prop", good=[ [ - "enumType/foo", - "enumType/bar", - "enumType/nolabel", + "foo", + "bar", + "nolabel", ] ], bad=[ diff --git a/tests/test_model_source.py b/tests/test_model_source.py index f964e0f..379c606 100644 --- a/tests/test_model_source.py +++ b/tests/test_model_source.py @@ -3,10 +3,11 @@ # # SPDX-License-Identifier: MIT -import os import shutil import subprocess import pytest +import rdflib +import json from pathlib import Path import shacl2code @@ -287,12 +288,8 @@ def test_context_url(model_server): def test_context_args(http_server): - shutil.copyfile( - TEST_CONTEXT, os.path.join(http_server.document_root, "context.json") - ) - shutil.copyfile( - TEST_CONTEXT, os.path.join(http_server.document_root, "context2.json") - ) + shutil.copyfile(TEST_CONTEXT, http_server.document_root / "context.json") + shutil.copyfile(TEST_CONTEXT, http_server.document_root / "context2.json") def do_test(*, contexts=[], url_contexts=[]): cmd = [ @@ -391,3 +388,59 @@ def test_model_errors(file): str(CONTEXT_TEMPLATE), ] ) + + +def test_context_contents(): + from rdflib import RDF, OWL, RDFS + + model = rdflib.Graph() + model.parse(TEST_MODEL) + + with TEST_CONTEXT.open("r") as f: + context = json.load(f) + + def check_prefix(iri, typ): + nonlocal context + + test_prefix = context["@context"]["test"] + + assert iri.startswith( + test_prefix + ), f"{typ} '{str(iri)}' does not have correct prefix {test_prefix}" + + name = iri[len(test_prefix) :].lstrip("/") + + return name + + def check_subject(iri, typ): + nonlocal context + + name = check_prefix(iri, typ) + + assert name in context["@context"], f"{typ} '{name}' missing from context" + + value = context["@context"][name] + return name, value + + for c in model.subjects(RDF.type, OWL.Class): + name, value = check_subject(c, "Class") + assert value == f"test:{name}", f"Class '{name}' has bad value '{value}'" + + for p in model.subjects(RDF.type, RDF.Property): + name, value = check_subject(p, "Property") + + assert model.objects(p, RDFS.range), f"Property '{name}' is missing rdfs:range" + assert isinstance(value, dict), f"Property '{name}' must be a dictionary" + + assert "@id" in value, f"Property '{name}' missing @id" + assert "@type" in value, f"Property '{name}' missing @type" + assert ( + value["@id"] == f"test:{name}" + ), f"Context '{name}' has bad @id '{value['@id']}'" + + for i in model.subjects(RDF.type, OWL.NamedIndividual): + name = check_prefix(i, "Named Individual") + + assert ( + name not in context + ), f"Named Individual '{name}' should not be in context" diff --git a/tests/test_python.py b/tests/test_python.py index eb94243..913751d 100644 --- a/tests/test_python.py +++ b/tests/test_python.py @@ -754,6 +754,7 @@ def type_tests(name, *typ): ), ("test_class_class_prop", lambda model: model.test_another_class(), TypeError), ("test_class_class_prop", lambda model: model.parent_class(), TypeError), + ("test_class_class_prop", lambda model: model.test_class.named, SAME_AS_VALUE), ("test_class_class_prop", "_:blanknode", "_:blanknode"), ( "test_class_class_prop", @@ -1477,7 +1478,7 @@ class OpenExtension(model.extensible_class): obj[TEST_IRI] = "foo" -def test_named_individuals(model): +def test_enum_named_individuals(model): assert type(model.enumType.foo) is str assert model.enumType.foo == "http://example.org/enumType/foo" @@ -1564,16 +1565,25 @@ def test_iri(model, roundtrip): def test_shacl(roundtrip): - model = rdflib.Graph() - model.parse(TEST_MODEL) + from rdflib import RDF, URIRef data = rdflib.Graph() data.parse(roundtrip) + # We need to add the referenced non-shape object, otherwise SHACL will + # complain it is missing + data.add( + ( + URIRef("http://serialize.example.com/non-shape"), + RDF.type, + URIRef("http://example.org/non-shape-class"), + ) + ) + conforms, result_graph, result_text = pyshacl.validate( data, - shacl_graph=model, - ont_graph=model, + shacl_graph=str(TEST_MODEL), + ont_graph=str(TEST_MODEL), ) assert conforms, result_text @@ -1619,6 +1629,11 @@ class Extension(model.extensible_class): expect.add(objset.find_by_id("http://serialize.example.com/test")) expect.add(objset.find_by_id("http://serialize.example.com/nested-parent")) + expect.add( + objset.find_by_id( + "http://serialize.example.com/test-named-individual-reference" + ) + ) expect.add( objset.find_by_id( "http://serialize.example.com/nested-parent" @@ -1685,6 +1700,16 @@ class Extension(model.extensible_class): == expect ) + # Test that concrete classes derived from abstract classes can be iterated + expect = set() + assert ( + set(objset.foreach_type(model.abstract_class, match_subclass=False)) == expect + ) + + expect.add(objset.find_by_id("http://serialize.example.com/concrete-class")) + assert expect != {None} + assert set(objset.foreach_type(model.abstract_class, match_subclass=True)) == expect + @pytest.mark.parametrize( "abstract,concrete", @@ -1703,3 +1728,69 @@ def test_abstract_classes(model, abstract, concrete): # implemented cls = getattr(model, concrete) cls() + + +def test_required_abstract_class_property(model, tmp_path): + # Test that a class with a required property that references an abstract + # class can be instantiated + c = model.required_abstract() + assert c.required_abstract_abstract_class_prop is None + + outfile = tmp_path / "out.json" + objset = model.SHACLObjectSet() + objset.add(c) + s = model.JSONLDSerializer() + + # Atempting to serialize without assigning the property should fail + with outfile.open("wb") as f: + with pytest.raises(ValueError): + s.write(objset, f, indent=4) + + # Assigning a concrete class should succeed and allow serialization + c.required_abstract_abstract_class_prop = model.concrete_class() + with outfile.open("wb") as f: + s.write(objset, f, indent=4) + + # Deleting an abstract class property should return it to None + del c.required_abstract_abstract_class_prop + assert c.required_abstract_abstract_class_prop is None + + +def test_extensible_abstract_class(model): + @model.register("http://example.org/custom-extension-class") + class Extension(model.extensible_abstract_class): + pass + + # Test that an extensible abstract class cannot be created + with pytest.raises(NotImplementedError): + model.extensible_abstract_class() + + # Test that a class derived from an abstract extensible class can be + # created + Extension() + + +def test_named_individual(model, roundtrip): + objset = model.SHACLObjectSet() + with roundtrip.open("r") as f: + d = model.JSONLDDeserializer() + d.read(f, objset) + + c = objset.find_by_id( + "http://serialize.example.com/test-named-individual-reference" + ) + assert c is not None + assert c.test_class_class_prop == model.test_class.named + + assert model.test_class.named not in objset.missing_ids + + +def test_missing_ids(model, roundtrip): + objset = model.SHACLObjectSet() + with roundtrip.open("r") as f: + d = model.JSONLDDeserializer() + d.read(f, objset) + + assert objset.missing_ids == { + "http://serialize.example.com/non-shape", + }