Data Formats
Data Formats
sgraph supports multiple data formats, each optimized for different use cases. This guide explains when and how to use each format.
Overview
| Format | Use Case | File Size | Performance | Human Readable |
|---|---|---|---|---|
| XML | Large models, production | Compact | Very Fast | Moderate |
| Deps | Scripting, simple analysis | Small | Fast | Very High |
| JSON | Web applications | Medium | Moderate | High |
| GraphML | Graph visualization tools | Large | Moderate | Low |
XML Format
The XML format is the primary format for sgraph, designed for performance and scalability.
Structure
<model version="2.1">
<elements t="architecture">
<e n="root_element">
<e n="child_element" i="2">
<r r="3" t="relationship_type" />
</e>
<e i="3" n="target_element">
</e>
</e>
</elements>
</model>
Key Features
- Integer References: Elements use integer IDs (
iattribute) for efficient relationships - Compact Representation: Minimal XML overhead
- Hierarchical Structure: Nested elements represent containment
- Relationships:
<r>tags define associations between elements - Attributes: Custom attributes stored as XML attributes
Attributes Reference
Element Attributes (<e>)
n- Name of the elementi- Unique integer identifiert- Type of the element- Custom attributes as needed
Relationship Attributes (<r>)
r- Reference to target element IDt- Type of relationship- Custom attributes for relationship metadata
Example: C Project Structure
<model version="2.1">
<elements t="c_project">
<e n="nginx">
<e n="src">
<e n="core">
<e n="nginx.c" t="source_file">
<r r="2" t="includes" />
<r r="3" t="includes" />
</e>
<e i="2" n="nginx.h" t="header_file">
</e>
<e i="3" n="config.h" t="header_file">
</e>
</e>
</e>
</e>
</elements>
</model>
Working with XML
from sgraph import SGraph
from sgraph.modelapi import ModelApi
# Create and export to XML
model = SGraph(SElement(None, ''))
# ... build your model ...
model.to_xml('output.xml')
# Load from XML
api = ModelApi(filepath='model.xml')
elements = api.getAllElements()
Deps Format
The Deps format is a simple, line-based text format perfect for scripting and quick analysis.
Structure
source_path:target_path:relationship_type
source_path:target_path:relationship_type:attribute1=value1,attribute2=value2
Examples
Basic Dependencies
/nginx/src/core/nginx.c:/nginx/src/core/nginx.h:includes
/nginx/src/core/nginx.c:/nginx/src/core/config.h:includes
/myapp/views.py:/myapp/models.py:imports
/myapp/views.py:/myapp/utils.py:imports
With Attributes
/api/user.py:/api/auth.py:imports:frequency=high,critical=true
/frontend/login.js:/api/auth.py:calls:method=POST,async=true
Hierarchical Elements Only
When no relationships exist, the format shows the hierarchical structure:
/nginx
/nginx/src
/nginx/src/core
/nginx/src/core/nginx.c
/nginx/src/core/nginx.h
Working with Deps Format
from sgraph import SGraph
from sgraph.converters.deps_to_xml import DepsToXml
from sgraph.converters.xml_to_deps import XmlToDeps
# Export to Deps
model.to_deps('dependencies.txt')
# Convert between formats
deps_to_xml = DepsToXml()
deps_to_xml.convert('dependencies.txt', 'model.xml')
xml_to_deps = XmlToDeps()
xml_to_deps.convert('model.xml', 'dependencies.txt')
Command Line Usage
# Convert XML to Deps
python -m sgraph.converters.xml_to_deps model.xml output.deps
# Convert Deps to XML
python -m sgraph.converters.deps_to_xml dependencies.txt model.xml
# Filter deps files
grep "\.py:" dependencies.txt > python_deps.txt
JSON Format
JSON format provides a web-friendly representation of sgraph models.
Structure
{
"model_version": "2.1",
"root": {
"name": "root",
"type": "root",
"children": [
{
"name": "module1",
"type": "module",
"attributes": {
"language": "python",
"lines": 150
},
"children": [],
"relationships": [
{
"target_path": "/module2",
"type": "imports",
"attributes": {}
}
]
}
]
}
}
Working with JSON
from sgraph.converters.sgraph_json import SGraphJson
from sgraph.converters.xml_to_json import XmlToJson
# Convert to JSON
converter = XmlToJson()
converter.convert('model.xml', 'model.json')
# Load JSON in web applications
import json
with open('model.json', 'r') as f:
model_data = json.load(f)
GraphML Format
GraphML is a standard XML format for graphs, supported by many visualization tools.
Features
- Compatible with Gephi, yEd, Cytoscape
- Rich metadata support
- Standard format for graph exchange
Working with GraphML
from sgraph.converters.xml_to_graphml import XmlToGraphMl
# Convert to GraphML
converter = XmlToGraphMl()
converter.convert('model.xml', 'graph.graphml')
# Import into visualization tools:
# - Gephi: File > Open > graph.graphml
# - yEd: File > Open > graph.graphml
# - Cytoscape: File > Import > Network from File
CycloneDX SBOM Format
sgraph.converters.sbom_cyclonedx_generator emits CycloneDX 1.7 documents. It can produce a
single SBOM for the whole model, one SBOM per element at a chosen tree depth (--level), or one
for a named element (--element-path).
# One SBOM per repository, for a model whose repositories sit at depth 3
python -m sgraph.converters.sbom_cyclonedx_generator model.xml sboms.json --level 3
The transitive dependency closure
By default a document lists the 3rd-party packages the analyzed code itself declares. A package that only another package depends on — the resolved closure a lockfile records — is left out, even when the model holds those package-to-package edges.
--transitive-externals follows them, so the whole closure reaches the document. It is opt-in:
the closure multiplies component counts, and every existing consumer of the default output keeps
receiving exactly what it received before. --max-depth N caps how deep the walk goes.
# Direct dependencies plus everything they pull in, no deeper than two hops
python -m sgraph.converters.sbom_cyclonedx_generator model.xml sboms.json \
--level 3 --transitive-externals --max-depth 2
Both options require --level or --element-path; the single-SBOM mode does not accept them.
--max-depth requires --transitive-externals, and must be 1 or greater: a smaller cap excludes
every component the walk could emit, so it is refused rather than rounded up to the shallowest
level.
| Field | Meaning |
|---|---|
properties[dependencyDepth] |
Package hops between the component and the analyzed code: 1 for a package the code declares, 2 for one that package pulls in, and so on. When a package is reachable by several routes, the shortest is reported — including routes through the internal elements a --transitive document inlines, so the depth a component publishes always agrees with where the dependencies section of the same document places it. |
- The property is present on every 3rd-party component of a
--transitive-externalsdocument, depth 1 included, and absent from every component of a default one. So an absent property means the document makes no depth claim — never “this package is direct”. - Only edges that mean package depends on package are followed (the manifest and lockfile deptypes). Code-level edges between externals are not: they describe how code is written, not what a manifest declares, and following them would list packages the project never depends on.
- A development-section declaration is followed exactly like a production one. Whether such a package should be scoped differently in the document is a separate question this option does not answer.
- The option is inert on a model that holds no package-to-package edges the converter
recognises: the components are the same, only the depth property is added. What a document
contains depends on what the analyzers stored, not on the flag alone. The recognised deptypes
are the manifest and lockfile ones —
packagejson,packagelock,pip,package_reference,nugetandpubspec, each also in itsdev_-prefixed form. Anything else between two externals is skipped. - An empty closure says so. When a document follows no package-to-package edge at all while edges between external elements were skipped for their deptype, one line naming those deptypes is written to stderr. Otherwise an unrecognised deptype and a model with no closure at all produce the same document, and nothing distinguishes them.
Which repository’s edges a document follows
The External subtree is project-wide: every repository’s resolved tree lands in the same
External/<ecosystem> elements and shares the versioned ones. A package-to-package edge on its
own therefore says nothing about which repository’s manifest declared it, and a naive closure
walks a sibling’s edges — putting packages in a document at versions that repository does not
install. Measured on two real repositories in one estate, 6 % and 4 % of the closure was another
repository’s.
The analyzers record the declaring scope on each such edge, as the model path of the directory
whose manifest declared it (the repository root for the ordinary lockfile that sits there). Where
several manifests declare the same edge, every scope is recorded — the paths joined by //,
a sequence that cannot occur inside a single model path.
An edge is followed when its declaring scope and the element the document is rooted at lie on the same root-to-leaf line: the scope is that element, an ancestor of it, or a descendant of it. Not merely “inside it” — a lockfile sits at a repository root while a directory-level document is rooted below it, so an “inside” test would empty the closure of every directory-level document.
An edge with no declaring scope is followed. Every model stored before the attribute existed carries none, and the pip and NuGet analyzers record none today. Absence means unknown provenance, and unknown provenance keeps the behaviour that was there before; reading it as “skip” would silently empty those closures.
With the closure, dependencies becomes a graph rather than a flat list: every package that
pulls in another gets an entry of its own, so a consumer can trace which package introduced an
exposure instead of only learning that it is present.
"dependencies": [
{ "ref": "repoa", "dependsOn": ["pkg:npm/express@4.18.2"] },
{ "ref": "pkg:npm/express@4.18.2", "dependsOn": ["pkg:npm/body-parser@1.20.1"] },
{ "ref": "pkg:npm/body-parser@1.20.1", "dependsOn": ["pkg:npm/qs@6.11.0"] }
]
- A package reached only through another one is listed under that package, not under the
element. Listing it under the element would assert a direct dependency no manifest declares,
and contradict the
dependencyDepththe same document publishes for it. - A package that is both declared and pulled in appears under both.
- Every ref resolves within the document, except
urn:cdx:BOM-Links, which name another document by design. - Without
--transitive-externalsthere are no package-to-package hops, so the section keeps the single flat entry it has always had.
Internal packages
A dependency that resolves to another element of the same estate is a third category: not the
document’s own subject, and not a 3rd-party package. It appears as a component marked
softagram:internal, alongside the urn:cdx: BOM-Link that has always been in dependsOn.
{ "bom-ref": "repob",
"type": "library",
"name": "ui-lib",
"version": "2.1.0",
"purl": "pkg:generic/ui-lib@2.1.0",
"group": "/OrgName/GroupA",
"properties": [
{ "name": "softagram:internal", "value": "true" },
{ "name": "softagram:packageName", "value": "ui-lib" },
{ "name": "softagram:packageEcosystem", "value": "npm" },
{ "name": "softagram:elementPath", "value": "/OrgName/GroupA/repoB" }
],
"externalReferences": [
{ "url": "urn:cdx:b02cf884-4fe6-5d96-8bab-a649ae9844b2/1", "type": "bom" },
{ "url": "https://example.org/org/repoB.git", "type": "vcs" }
] }
| Field | Meaning |
|---|---|
name / version / purl |
The package the element publishes, when the model says unambiguously which one that is. Otherwise the element’s own name, an empty version and an empty purl. |
properties[softagram:internal] |
Always "true" on these components. |
properties[softagram:packageName] |
The published package name, the same one spliced into the purl. |
properties[softagram:packageEcosystem] |
The ecosystem the package is published in (npm, pypi, …). Absent when the model does not name one; it decides no part of the purl. |
externalReferences[type=bom] |
BOM-Link to that element’s own standalone document. |
- The purl type is
generic, never the ecosystem’s own type.pkg:npm/<name>@<version>would assert an identity in the public npm registry. Either the name is not published there, in which case the npm type buys nothing, or it is and belongs to someone else, in which case the component silently inherits a stranger’s advisories. The ecosystem is published as a property instead, so nothing is lost but the false claim. - The dependency is emitted both as this component and as a
urn:cdx:BOM-Link independsOn. The link federates to the element’s own document for a consumer that follows links across uploads; the component is what a consumer that does not follow them can see at all. - Only direct internal dependencies are inlined into a default document.
--transitiveinlines the whole chain of them, together with the 3rd-party components of every link. - In a default document the component appears only when the element publishes a package
identity; without one,
dependsOncarries the BOM-Link alone, exactly as before. A component named after a repository or a directory, with no version and no purl, is the very shape the missing-identifier problem is about, and until an analyzer stamps the identity attributes every such component on an existing model would come out that way. So the default document changes for a consumer only once the row can carry real coordinates. --transitivedeliberately does not take that rule. Inlining internal elements is what that option has always done and its consumers already receive those rows; identity improves them where it exists, but its absence must not delete a dependency a consumer can see today. The asymmetry is intentional, not an inconsistency.- How many of these a document holds scales with the granularity you select. They are not a
fixed overhead: they are one component per element the chosen element directly depends on, so
the count follows the
--level(or--element-path) you pass. A repository-level export usually gains a handful. A directory-level export splits the same estate into far more, and finer, elements, so dependencies that were internal to one repository become cross-element ones — on a large repository a single directory-level document has been measured gaining several hundred. That is the number of dependencies that directory genuinely has, not a closure being walked; if it is more than you want, export at a coarser level. - An element that publishes several packages with nothing to choose between them is given no
identity at all: it keeps the element name and carries no
purlkey. No coordinates are invented. (Before 1.13.0 the key was present and empty, which CycloneDX types as an iri-reference and the empty string is not one.) - Components describing internal packages carry no
dependencyDepth. That property counts package hops through the 3rd-party closure, and an element of the estate is not one.
What the document does not cover
A component list cannot say what is missing from it. An external the analyzer saw and the generator could not identify leaves no trace, so a consumer cannot tell “this estate depends on nothing else” from “we could not identify the rest”.
--coverage says it, in single-SBOM mode:
python -m sgraph.converters.sbom_cyclonedx_generator model.xml sbom.json --coverage
It adds two things. Four metadata.properties counting what was and was not identified under
External — components emitted, not a package, version unknown by design, could not identify —
and one compositions entry stating whether the third-party assembly is complete:
"compositions": [{ "aggregate": "incomplete", "assemblies": ["/Org"] }]
aggregate |
When |
|---|---|
incomplete |
Externals were seen that could not be identified. |
unknown |
Everything seen was identified. Whether everything was seen is a different question. |
not_specified |
No External subtree at all, so there is no basis for a claim. |
completeis never emitted. CycloneDX defines it as “no further relationships … are KNOWN to exist”. The report proves only that every element the walk saw was classified, never that the analyzer saw everything — an analyzer that was not run leaves no evidence that it was missing. Claiming completeness makes a consumer stop looking, so the strongest honest claim isunknown, which the specification defines as a best-effort whose completeness is inconclusive.- Single-SBOM mode only. The report is model-wide, so attaching it to a
--levelor--element-pathdocument would claim that that document’s assembly is incomplete because some other subtree’s is.--coveragecombined with either is refused rather than emitted with a caveat. - Opt-in. Without the flag the document is exactly what earlier releases produced.
- The four counts are properties rather than part of the composition because a composition holds
aggregate,assemblies,dependenciesandvulnerabilitiesand nothing else. Completeness fits the standard; a ten-category taxonomy with counts and samples does not.
Where an element lives
Every component that describes a model element — the metadata component of each document, and every internal component — publishes its position in the model:
{ "bom-ref": "repoa",
"name": "repoA",
"group": "/OrgName/GroupA",
"type": "application",
"version": "",
"purl": "",
"externalReferences": [
{ "url": "https://example.org/org/repoA.git", "type": "vcs" }
],
"properties": [
{ "name": "softagram:elementPath", "value": "/OrgName/GroupA/repoA" }
] }
| Field | Meaning |
|---|---|
group |
The full path of the parent element, not just its name. Omitted for a top-level element, which has no parent path. |
properties[softagram:elementPath] |
The element’s own full path. A property rather than a field because the CycloneDX component schema sets additionalProperties: false. |
externalReferences[type=vcs] |
The repo_url of the element, or of the nearest ancestor carrying a non-blank one. Absent when no ancestor has one — never a placeholder. |
Components describing 3rd-party packages carry none of these. Their identity is the purl.
Guarantees
- Every
properties[].valueis a JSON string, including the ones that carry a number. CycloneDX types the field as a string in every spec version this converter emits, and setsadditionalProperties: false, so a numeric value is not merely untidy: a validating consumer rejects the whole document rather than the one field. Counts such asindirectExposureCountare therefore published as"3", not3, and a consumer reading them numerically has to parse them — the same as fordependencyDepth. deterministic_serial(elementPath) == serialNumber. The published path is the exact string the serial is derived from, so a consumer can verify a document’s identity without the model.group + '/' + name == elementPathbelow the top level.- Two repositories that share a name under different groups are distinguished by
groupandelementPath. They are not reliably distinguished bybom-ref, whose collision suffix (repoa,repoa-2) depends on traversal order and can change between model generations.
Caveats
- The first path segment is the estate root and is not stable. It changes when the estate is renamed or restructured. Read it from the path rather than hardcoding it.
groupholds a path, not a package namespace. The CycloneDX specification suggests avoiding special characters ingroupand shows package coordinates such asorg.apache.commons. A model group is a tree location, and the slash-delimited path is what makes two identically named groups distinguishable, so this converter prefers precision over that convention. Tools that rendergroupas a package coordinate will show the path.purlandversionare empty on the metadata component, and on an internal component whose element publishes no unambiguous package. A repository has no package identity and no version of its own; a path is not a valid purl and is deliberately not placed there. An internal component whose element does publish one carries that package’s coordinates — see Internal packages.- The vcs reference is inherited by proximity. A repository with no remote of its own, under a group that has one, reports the group’s URL.
Format Comparison
Performance Benchmarks
| Format | 1K Elements | 100K Elements | 1M Elements |
|---|---|---|---|
| XML Load | 10ms | 500ms | 5s |
| Deps Load | 5ms | 200ms | 2s |
| JSON Load | 15ms | 800ms | 8s |
| GraphML Load | 20ms | 1.2s | 12s |
File Size Comparison
For a typical software project with 10K elements:
| Format | File Size | Compression Ratio |
|---|---|---|
| XML | 2.5 MB | 1.0x (baseline) |
| Deps | 800 KB | 3.1x smaller |
| JSON | 4.2 MB | 1.7x larger |
| GraphML | 8.1 MB | 3.2x larger |
Best Practices
Choosing the Right Format
Use XML when:
- Working with large models (>10K elements)
- Need maximum performance
- Building production systems
- Preserving all metadata and attributes
Use Deps when:
- Simple dependency analysis
- Scripting and automation
- Human-readable output needed
- Working with shell tools (grep, awk, etc.)
Use JSON when:
- Building web applications
- Need JavaScript compatibility
- Creating REST APIs
- Moderate-sized models (<50K elements)
Use GraphML when:
- Importing into visualization tools
- Sharing with researchers
- Need standards compliance
- One-time analysis tasks
Performance Optimization
For Large Models
# Use XML format for storage
model.to_xml('large_model.xml')
# Load with ModelApi for efficient querying
api = ModelApi(filepath='large_model.xml')
# Use specific queries instead of loading all elements
functions = api.getElementsByType('function')
specific_elements = api.getElementsByName('main')
For Streaming Processing
# Process deps format line by line for very large files
def process_large_deps_file(filepath):
with open(filepath, 'r') as f:
for line in f:
if ':' in line:
parts = line.strip().split(':')
source, target, rel_type = parts[:3]
# Process dependency
yield source, target, rel_type
Memory Management
# For very large models, use streaming
from sgraph.loader.modelloader import ModelLoader
# Load incrementally
loader = ModelLoader()
for element_batch in loader.load_streaming('huge_model.xml', batch_size=1000):
# Process batch
process_elements(element_batch)
Migration Between Formats
Preserving Metadata
When converting between formats, be aware of metadata preservation:
| From → To | Elements | Relationships | Attributes | Performance |
|---|---|---|---|---|
| XML → Deps | ✅ Paths only | ✅ | ⚠️ Limited | Fast |
| XML → JSON | ✅ | ✅ | ✅ | Medium |
| XML → GraphML | ✅ | ✅ | ✅ | Slow |
| Deps → XML | ✅ | ✅ | ⚠️ Limited | Fast |
Batch Conversion
import os
from sgraph.converters.xml_to_deps import XmlToDeps
def convert_project_models(input_dir, output_dir):
"""Convert all XML models to Deps format"""
converter = XmlToDeps()
for filename in os.listdir(input_dir):
if filename.endswith('.xml'):
input_path = os.path.join(input_dir, filename)
output_path = os.path.join(output_dir, filename.replace('.xml', '.deps'))
print(f"Converting {filename}...")
converter.convert(input_path, output_path)
# Usage
convert_project_models('models/', 'deps_output/')
Advanced Features
Custom Attributes in XML
<e n="MyClass" t="class" visibility="public" complexity="high" loc="250">
<r r="2" t="inherits" strength="strong" />
</e>
Relationship Attributes in Deps
/src/main.py:/src/utils.py:imports:frequency=10,last_used=2023-12-01
Nested Attributes in JSON
{
"name": "MyFunction",
"attributes": {
"metrics": {
"complexity": 15,
"lines": 45,
"parameters": 3
},
"metadata": {
"author": "developer",
"last_modified": "2023-12-01"
}
}
}
Understanding these formats allows you to choose the right tool for each task and integrate sgraph into your development workflow effectively!