Skip to content

Commit 2feba12

Browse files
[Add] ArchiveSession
1 parent 8e74c23 commit 2feba12

File tree

7 files changed

+684
-119
lines changed

7 files changed

+684
-119
lines changed
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
namespace SysML2.NET.ModelInterchange
2+
{
3+
using System;
4+
using System.Collections.Generic;
5+
using System.IO;
6+
using System.Linq;
7+
using System.Threading;
8+
using System.Threading.Tasks;
9+
10+
using SysML2.NET.Extensions;
11+
12+
/// <summary>
13+
/// Provides convenience methods for working with <see cref="Archive"/> instances.
14+
/// </summary>
15+
public static class ArchiveExtensions
16+
{
17+
/// <summary>
18+
/// Tries to resolve a <see cref="ModelEntry"/> by metadata index key.
19+
/// </summary>
20+
/// <param name="archive">The <see cref="Archive"/> to query.</param>
21+
/// <param name="indexKey">
22+
/// The key in <see cref="InterchangeProjectMetadata.Index"/> that identifies a model file
23+
/// (for example <c>"Base"</c> maps to <c>"Base.kerml"</c>).
24+
/// </param>
25+
/// <param name="entry">
26+
/// When this method returns, contains the resolved <see cref="ModelEntry"/> if found;
27+
/// otherwise, <see langword="null"/>.
28+
/// </param>
29+
/// <returns>
30+
/// <see langword="true"/> if an entry is resolved; otherwise, <see langword="false"/>.
31+
/// </returns>
32+
public static bool TryGetModelEntryByIndexKey(this Archive archive, string indexKey, out ModelEntry entry)
33+
{
34+
if (archive == null)
35+
{
36+
throw new ArgumentNullException(nameof(archive));
37+
}
38+
39+
if (string.IsNullOrWhiteSpace(indexKey))
40+
{
41+
throw new ArgumentException("The index key shall not be null or empty.", nameof(indexKey));
42+
}
43+
44+
entry = null;
45+
46+
var map = archive.Metadata?.Index;
47+
if (map is null) return false;
48+
49+
if (!map.TryGetValue(indexKey, out var path) || string.IsNullOrWhiteSpace(path))
50+
{
51+
return false;
52+
}
53+
54+
var normalized = path.NormalizeZipPath();
55+
56+
entry = archive.Models?.FirstOrDefault(modelEntry =>
57+
string.Equals(modelEntry.Path.NormalizeZipPath(), normalized, StringComparison.Ordinal));
58+
59+
return entry is not null;
60+
}
61+
62+
/// <summary>
63+
/// Resolves a <see cref="ModelEntry"/> by metadata index key.
64+
/// </summary>
65+
/// <param name="archive">The <see cref="Archive"/> to query.</param>
66+
/// <param name="indexKey">The metadata index key identifying the model file.</param>
67+
/// <returns>The resolved <see cref="ModelEntry"/>.</returns>
68+
/// <exception cref="ArgumentNullException">Thrown when <paramref name="archive"/> is <see langword="null"/>.</exception>
69+
/// <exception cref="ArgumentException">Thrown when <paramref name="indexKey"/> is <see langword="null"/> or empty.</exception>
70+
/// <exception cref="InvalidOperationException">
71+
/// Thrown when the archive does not contain metadata index information.
72+
/// </exception>
73+
/// <exception cref="KeyNotFoundException">
74+
/// Thrown when the metadata index does not contain <paramref name="indexKey"/>.
75+
/// </exception>
76+
/// <exception cref="FileNotFoundException">
77+
/// Thrown when the metadata index points to a path that is not present in <see cref="Archive.Models"/>.
78+
/// </exception>
79+
public static ModelEntry GetModelEntryByIndexKey(this Archive archive, string indexKey)
80+
{
81+
if (archive is null) throw new ArgumentNullException(nameof(archive));
82+
if (string.IsNullOrWhiteSpace(indexKey))
83+
{
84+
throw new ArgumentException("The index key shall not be null or empty.", nameof(indexKey));
85+
}
86+
87+
var map = archive.Metadata?.Index
88+
?? throw new InvalidOperationException("Archive metadata index is not available.");
89+
90+
if (!map.TryGetValue(indexKey, out var path) || string.IsNullOrWhiteSpace(path))
91+
{
92+
throw new KeyNotFoundException($"No index entry found for key '{indexKey}'.");
93+
}
94+
95+
var normalized = path.NormalizeZipPath();
96+
97+
var entry = archive.Models?.FirstOrDefault(m =>
98+
string.Equals(m.Path.NormalizeZipPath(), normalized, StringComparison.Ordinal));
99+
100+
if (entry is null)
101+
{
102+
throw new FileNotFoundException(
103+
$"Model entry for index key '{indexKey}' points to missing path '{normalized}'.",
104+
normalized);
105+
}
106+
107+
return entry;
108+
}
109+
110+
/// <summary>
111+
/// Opens the model content stream associated with the specified metadata index key.
112+
/// </summary>
113+
/// <param name="archive">The <see cref="Archive"/> to query.</param>
114+
/// <param name="indexKey">The metadata index key identifying the model file.</param>
115+
/// <param name="cancellationToken">The cancellation token used to cancel the operation.</param>
116+
/// <returns>
117+
/// A task that returns a readable stream for the model content.
118+
/// </returns>
119+
public static async Task<Stream> OpenModelByIndexKeyAsync(this Archive archive, string indexKey, CancellationToken cancellationToken = default)
120+
{
121+
var entry = archive.GetModelEntryByIndexKey(indexKey);
122+
return await entry.OpenReadAsync(cancellationToken);
123+
}
124+
}
125+
}
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
// -------------------------------------------------------------------------------------------------
2+
// <copyright file="StringExtensions.cs" company="Starion Group S.A.">
3+
//
4+
// Copyright 2022-2026 Starion Group S.A.
5+
//
6+
// Licensed under the Apache License, Version 2.0 (the "License");
7+
// you may not use this file except in compliance with the License.
8+
// You may obtain a copy of the License at
9+
//
10+
// http://www.apache.org/licenses/LICENSE-2.0
11+
//
12+
// Unless required by applicable law or agreed to in writing, software
13+
// distributed under the License is distributed on an "AS IS" BASIS,
14+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15+
// See the License for the specific language governing permissions and
16+
// limitations under the License.
17+
//
18+
// </copyright>
19+
// ------------------------------------------------------------------------------------------------
20+
21+
namespace SysML2.NET.Extensions
22+
{
23+
using System;
24+
25+
/// <summary>
26+
/// The <see cref="StringExtensions"/> class provides extensions methods for <see cref="String"/>
27+
/// </summary>
28+
public static class StringExtensions
29+
{
30+
/// <summary>
31+
/// Normalizes a ZIP entry path by converting directory separators to forward
32+
/// slashes (<c>'/'</c>) and removing any leading <c>"./"</c> segment.
33+
/// </summary>
34+
/// <param name="path">
35+
/// The ZIP entry path to normalize.
36+
/// </param>
37+
/// <returns>
38+
/// A normalized path using forward slashes as directory separators and
39+
/// without a leading <c>"./"</c> segment, if present.
40+
/// </returns>
41+
public static string NormalizeZipPath(this string path)
42+
{
43+
if (path == null)
44+
{
45+
throw new ArgumentNullException(nameof(path));
46+
}
47+
48+
path = path.Replace('\\', '/');
49+
50+
while (path.StartsWith("./", StringComparison.Ordinal))
51+
{
52+
path = path.Substring(2);
53+
}
54+
55+
return path;
56+
}
57+
}
58+
}

SysML2.NET.Kpar.Tests/ReaderTestFixture.cs

Lines changed: 70 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -76,78 +76,122 @@ public void Verify_that_kpar_contents_can_be_read_from_path()
7676
}
7777

7878
[Test]
79-
public void Verify_that_kpar_contents_can_be_read_from_stream_leaveOpen_false()
79+
public void Verify_that_kpar_contents_can_be_read_from_stream()
8080
{
8181
var kparPath = GetKparPath();
8282
Assert.That(File.Exists(kparPath), Is.True, $"KPAR test file not found: {kparPath}");
8383

84-
using var stream = File.OpenRead(kparPath);
84+
using var fileStream = File.OpenRead(kparPath);
8585

86-
var archive = this.reader.Read(stream, leaveOpen: false);
86+
var archive = this.reader.Read(fileStream);
8787

8888
AssertArchive(archive, expectedPath: null);
89-
90-
Assert.That(stream.CanRead, Is.False, "Stream should be disposed when leaveOpen=false.");
9189
}
9290

9391
[Test]
94-
public void Verify_that_kpar_contents_can_be_read_from_stream_leaveOpen_true()
92+
public async Task Verify_that_kpar_contents_can_be_read_async_from_path()
9593
{
9694
var kparPath = GetKparPath();
9795
Assert.That(File.Exists(kparPath), Is.True, $"KPAR test file not found: {kparPath}");
9896

99-
using var stream = File.OpenRead(kparPath);
97+
var archive = await this.reader.ReadAsync(kparPath).ConfigureAwait(false);
10098

101-
var archive = this.reader.Read(stream, leaveOpen: true);
99+
AssertArchive(archive, expectedPath: kparPath);
100+
}
102101

103-
AssertArchive(archive, expectedPath: null);
102+
[Test]
103+
public async Task Verify_that_kpar_contents_can_be_read_async_from_stream()
104+
{
105+
var kparPath = GetKparPath();
106+
Assert.That(File.Exists(kparPath), Is.True, $"KPAR test file not found: {kparPath}");
107+
108+
await using var fileStream = File.OpenRead(kparPath);
104109

105-
Assert.That(stream.CanRead, Is.True, "Stream should remain open when leaveOpen=true.");
110+
var archive = await this.reader.ReadAsync(fileStream).ConfigureAwait(false);
106111

107-
// extra sanity: position should be advanced, but stream still usable
108-
Assert.That(stream.Position, Is.GreaterThan(0));
112+
AssertArchive(archive, expectedPath: null);
109113
}
110114

111115
[Test]
112-
public async Task Verify_that_kpar_contents_can_be_read_async_from_path()
116+
public void Verify_that_kpar_can_be_opened_from_path_and_model_streams_can_be_opened()
113117
{
114118
var kparPath = GetKparPath();
115119
Assert.That(File.Exists(kparPath), Is.True, $"KPAR test file not found: {kparPath}");
116120

117-
var archive = await this.reader.ReadAsync(kparPath).ConfigureAwait(false);
121+
using var archiveSession = this.reader.Open(kparPath);
118122

119-
AssertArchive(archive, expectedPath: kparPath);
123+
AssertArchive(archiveSession.Archive, expectedPath: kparPath);
124+
125+
using var modelStream = archiveSession.OpenModel("Base");
126+
Assert.That(modelStream, Is.Not.Null);
127+
Assert.That(modelStream.CanRead, Is.True);
128+
129+
Assert.That(modelStream.Length, Is.GreaterThan(0));
120130
}
131+
132+
[Test]
133+
public async Task Verify_that_kpar_can_be_opened_async_from_path_and_model_streams_can_be_opened()
134+
{
135+
var kparPath = GetKparPath();
136+
137+
Assert.That(File.Exists(kparPath), Is.True, $"KPAR test file not found: {kparPath}");
121138

139+
await using var archiveSession = await this.reader.OpenAsync(kparPath).ConfigureAwait(false);
140+
141+
AssertArchive(archiveSession.Archive, expectedPath: kparPath);
142+
143+
await using var modelStream = archiveSession.OpenModel("Base");
144+
Assert.That(modelStream, Is.Not.Null);
145+
Assert.That(modelStream.CanRead, Is.True);
146+
Assert.That(modelStream.Length, Is.GreaterThan(0));
147+
}
148+
122149
[Test]
123-
public async Task Verify_that_kpar_contents_can_be_read_async_from_stream_leaveOpen_false()
150+
public void Verify_that_opened_entry_streams_become_invalid_after_session_dispose()
124151
{
125152
var kparPath = GetKparPath();
126153
Assert.That(File.Exists(kparPath), Is.True, $"KPAR test file not found: {kparPath}");
127154

128-
await using var stream = File.OpenRead(kparPath);
155+
var archiveSession = this.reader.Open(kparPath);
129156

130-
var archive = await this.reader.ReadAsync(stream, leaveOpen: false).ConfigureAwait(false);
157+
var entryStream = archiveSession.OpenModel("Base");
158+
Assert.That(entryStream.CanRead, Is.True);
131159

132-
AssertArchive(archive, expectedPath: null);
160+
archiveSession.Dispose();
133161

134-
Assert.That(stream.CanRead, Is.False, "Stream should be disposed when leaveOpen=false.");
162+
Assert.That(() => _ = entryStream.ReadByte(), Throws.Exception,
163+
"Reading from an entry stream after session disposal should fail.");
135164
}
136165

137166
[Test]
138-
public async Task Verify_that_kpar_contents_can_be_read_async_from_stream_leaveOpen_true()
167+
public async Task Verify_that_kpar_can_be_opened_async_from_stream_and_source_is_disposed_on_session_dispose()
139168
{
140169
var kparPath = GetKparPath();
141170
Assert.That(File.Exists(kparPath), Is.True, $"KPAR test file not found: {kparPath}");
142171

143-
await using var stream = File.OpenRead(kparPath);
172+
var source = File.OpenRead(kparPath);
144173

145-
var archive = await this.reader.ReadAsync(stream, leaveOpen: true).ConfigureAwait(false);
174+
ArchiveSession archiveSession = null;
146175

147-
AssertArchive(archive, expectedPath: null);
176+
try
177+
{
178+
archiveSession = await this.reader.OpenAsync(source).ConfigureAwait(false);
179+
180+
AssertArchive(archiveSession.Archive, expectedPath: null);
181+
182+
await using var modelStream = archiveSession.OpenModel("Base");
183+
Assert.That(modelStream.CanRead, Is.True);
184+
Assert.That(modelStream.Length, Is.GreaterThan(0));
185+
}
186+
finally
187+
{
188+
if (archiveSession is not null)
189+
{
190+
await archiveSession.DisposeAsync().ConfigureAwait(false);
191+
}
192+
}
148193

149-
Assert.That(stream.CanRead, Is.True, "Stream should remain open when leaveOpen=true.");
150-
Assert.That(stream.Position, Is.GreaterThan(0));
194+
Assert.That(source.CanRead, Is.False, "Source stream should be disposed .");
151195
}
152196

153197
/// <summary>

0 commit comments

Comments
 (0)