matlab-versions 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,319 @@
1
+ """Translate between the identifiers of a MATLAB release.
2
+
3
+ Every MATLAB release has several numbers, and they are easy to mix up:
4
+
5
+ ========= ============== =============== =============== ===============
6
+ Release MATLAB version Runtime version Runtime folder FlexLM (lmstat)
7
+ ========= ============== =============== =============== ===============
8
+ R2016a 9.0 9.0.1 v901 v35
9
+ R2021a 9.10 9.10 v910 v45
10
+ R2024a 24.1 24.1 R2024a v51
11
+ ========= ============== =============== =============== ===============
12
+
13
+ * the **MATLAB version** is what ``version()`` and ``VersionInfo.xml`` report;
14
+ * the **MATLAB Runtime version** is what compiled applications need, installed
15
+ under a folder named ``v910`` (up to R2022a) or ``R2024a`` (from R2022b);
16
+ * the **licence version** is what a client checks out from a FlexLM licence
17
+ server, printed by ``lmutil lmstat -a`` on every checkout line as ``(v51)``.
18
+
19
+ The bundled table (``versions.json``) covers R2006a onwards and records, for
20
+ each value, how well it is supported and where it comes from. Releases newer
21
+ than the table are computed from the current rules and marked
22
+ ``"extrapolated"``.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import json
28
+ import re
29
+ from dataclasses import dataclass, field
30
+ from importlib.resources import files
31
+ from typing import Dict, Iterable, Iterator, List, Mapping, Optional, Tuple, Union
32
+
33
+ __all__ = [
34
+ "Checkout",
35
+ "Release",
36
+ "data",
37
+ "from_license",
38
+ "from_matlab",
39
+ "from_runtime",
40
+ "lookup",
41
+ "parse_checkout",
42
+ "parse_lmstat",
43
+ "release",
44
+ "releases",
45
+ ]
46
+ __version__ = "0.1.0"
47
+
48
+ #: Oldest licence version the +1 rule holds for (R2007a).
49
+ FIRST_LICENSE = 17
50
+
51
+ _NAME_RE = re.compile(r"^\s*R?(?P<year>\d{4})(?P<half>[ab])\s*$", re.IGNORECASE)
52
+ _LICENSE_RE = re.compile(r"^\s*v?(?P<major>\d+)(?:\.\d+)?\s*$", re.IGNORECASE)
53
+ _DOTTED_RE = re.compile(r"^\s*(?P<version>\d+(?:\.\d+)+)(?:\s.*)?$")
54
+ _FOLDER_RE = re.compile(r"^\s*v(?P<digits>\d{2,4})\s*$", re.IGNORECASE)
55
+ _FEATURE_RE = re.compile(r"^Users of\s+(?P<feature>\S+?):", re.IGNORECASE)
56
+ _CHECKOUT_RE = re.compile(
57
+ r"^\s+(?P<user>\S+)\s+(?P<host>\S+)\s+(?:(?P<display>.*?)\s+)?"
58
+ r"\(v(?P<version>\d+)(?:\.\d+)?\)\s+"
59
+ r"\((?P<server>[^/\s]+)/(?P<port>\d+)\s+(?P<handle>\d+)\)"
60
+ r"(?:,\s+start\s+(?P<start>.+?))?"
61
+ r"(?:,\s+PID:\s*(?P<pid>\d+))?\s*$"
62
+ )
63
+
64
+
65
+ @dataclass(frozen=True)
66
+ class Release:
67
+ """One MATLAB release and its identifiers. Missing values are ``None``."""
68
+
69
+ name: str
70
+ """Release name, e.g. ``"R2024a"``."""
71
+ matlab: Optional[str]
72
+ """MATLAB version as ``version()`` reports it, major.minor only, e.g. ``"24.1"``."""
73
+ runtime: Optional[str]
74
+ """MATLAB Runtime version, e.g. ``"24.1"`` (``"9.0.1"`` for R2016a)."""
75
+ license: Optional[int]
76
+ """FlexLM licence version, e.g. ``51`` (shown by lmstat as ``(v51)``)."""
77
+ evidence: Mapping[str, str] = field(default_factory=dict, compare=False, hash=False)
78
+ """Per field: ``"observed"``, ``"documented"`` or ``"extrapolated"`` (see ``data()``)."""
79
+ sources: Mapping[str, Tuple[str, ...]] = field(default_factory=dict, compare=False, hash=False)
80
+ """Per field: keys into ``data()["sources"]``."""
81
+
82
+ @property
83
+ def year(self) -> int:
84
+ return int(self.name[1:5])
85
+
86
+ @property
87
+ def half(self) -> str:
88
+ return self.name[5]
89
+
90
+ @property
91
+ def runtime_folder(self) -> Optional[str]:
92
+ """Folder the MATLAB Runtime installs under: ``"v910"`` up to R2022a, ``"R2022b"`` after."""
93
+ if self.runtime is None:
94
+ return None
95
+ if (self.year, self.half) >= (2022, "b"):
96
+ return self.name
97
+ return "v" + self.runtime.replace(".", "")
98
+
99
+ def __str__(self) -> str:
100
+ return self.name
101
+
102
+
103
+ @dataclass(frozen=True)
104
+ class Checkout:
105
+ """One checkout line from ``lmutil lmstat -a``."""
106
+
107
+ user: str
108
+ host: str
109
+ display: Optional[str]
110
+ version: int
111
+ """Licence version the client checked out, e.g. ``51``."""
112
+ server: str
113
+ port: int
114
+ handle: int
115
+ start: Optional[str]
116
+ pid: Optional[int]
117
+ feature: Optional[str] = None
118
+ """The ``Users of <feature>:`` section the line was in (only from :func:`parse_lmstat`)."""
119
+
120
+ @property
121
+ def release(self) -> Optional[Release]:
122
+ """The client's MATLAB release, or ``None`` for licence versions before R2007a."""
123
+ try:
124
+ return from_license(self.version)
125
+ except ValueError:
126
+ return None
127
+
128
+
129
+ _DATA: Optional[dict] = None
130
+ _BY_NAME: Dict[str, Release] = {}
131
+
132
+
133
+ def data() -> dict:
134
+ """The bundled table: description, rules, caveats, evidence levels, sources and releases."""
135
+ global _DATA
136
+ if _DATA is None:
137
+ text = files(__package__).joinpath("versions.json").read_text(encoding="utf-8")
138
+ _DATA = json.loads(text)
139
+ for row in _DATA["releases"]:
140
+ rel = Release(
141
+ name=row["release"],
142
+ matlab=row.get("matlab"),
143
+ runtime=row.get("runtime"),
144
+ license=row.get("license"),
145
+ evidence=dict(row.get("evidence", {})),
146
+ sources={k: tuple(v) for k, v in row.get("sources", {}).items()},
147
+ )
148
+ _BY_NAME[rel.name] = rel
149
+ return _DATA
150
+
151
+
152
+ def releases() -> Tuple[Release, ...]:
153
+ """All releases in the bundled table, oldest first."""
154
+ data()
155
+ return tuple(sorted(_BY_NAME.values(), key=lambda r: (r.year, r.half)))
156
+
157
+
158
+ def _newest() -> Release:
159
+ return releases()[-1]
160
+
161
+
162
+ def _is_newer(year: int, half: str) -> bool:
163
+ newest = _newest()
164
+ return (year, half) > (newest.year, newest.half)
165
+
166
+
167
+ def _extrapolate(year: int, half: str) -> Release:
168
+ """A release newer than the table, from the rules that hold since R2023b."""
169
+ ver = f"{year % 100}.{1 if half == 'a' else 2}"
170
+ lic = 2 * (year - 2008) + 19 + (half == "b")
171
+ return Release(
172
+ name=f"R{year}{half}", matlab=ver, runtime=ver, license=lic,
173
+ evidence={"matlab": "extrapolated", "runtime": "extrapolated", "license": "extrapolated"},
174
+ )
175
+
176
+
177
+ def release(name: str) -> Release:
178
+ """Release by name: ``"R2024a"``, ``"2024a"`` or ``"r2024A"``."""
179
+ m = _NAME_RE.match(str(name))
180
+ if not m:
181
+ raise ValueError(f"not a release name like 'R2024a': {name!r}")
182
+ year, half = int(m.group("year")), m.group("half").lower()
183
+ data()
184
+ found = _BY_NAME.get(f"R{year}{half}")
185
+ if found:
186
+ return found
187
+ if _is_newer(year, half):
188
+ return _extrapolate(year, half)
189
+ raise ValueError(f"R{year}{half} is not covered (the table starts at {releases()[0].name})")
190
+
191
+
192
+ def from_license(ver: Union[int, str]) -> Release:
193
+ """Release whose clients check out this licence version: ``51``, ``"v51"``, ``"v51.0"``."""
194
+ if isinstance(ver, bool):
195
+ raise TypeError("licence version must be an int or str")
196
+ if isinstance(ver, str):
197
+ m = _LICENSE_RE.match(ver)
198
+ if not m:
199
+ raise ValueError(f"not a licence version: {ver!r}")
200
+ ver = int(m.group("major"))
201
+ if ver < FIRST_LICENSE:
202
+ raise ValueError(f"licence version {ver} predates R2007a, where the numbering differs")
203
+ for rel in releases():
204
+ if rel.license == ver:
205
+ return rel
206
+ steps = ver - 19
207
+ return _extrapolate(2008 + steps // 2, "ab"[steps % 2])
208
+
209
+
210
+ def _two_part(version: str) -> str:
211
+ return ".".join(version.split(".")[:2])
212
+
213
+
214
+ def from_matlab(ver: str) -> Release:
215
+ """Release for a MATLAB version: ``"24.1"``, ``"9.10"`` or a full ``version()`` string
216
+ such as ``"9.10.0.1739362 (R2021a)"``."""
217
+ m = _DOTTED_RE.match(str(ver))
218
+ if not m:
219
+ raise ValueError(f"not a MATLAB version like '24.1': {ver!r}")
220
+ wanted = _two_part(m.group("version"))
221
+ for rel in releases():
222
+ if rel.matlab == wanted:
223
+ return rel
224
+ major, minor = (int(x) for x in wanted.split("."))
225
+ if major >= 23 and minor in (1, 2) and _is_newer(2000 + major, "ab"[minor - 1]):
226
+ return _extrapolate(2000 + major, "ab"[minor - 1])
227
+ raise ValueError(f"unknown MATLAB version: {ver!r}")
228
+
229
+
230
+ def from_runtime(ver: str) -> Release:
231
+ """Release for a MATLAB Runtime version or install folder:
232
+ ``"9.14"``, ``"9.0.1"``, ``"v914"``, ``"v901"`` or ``"R2023b"``."""
233
+ text = str(ver).strip()
234
+ if _NAME_RE.match(text):
235
+ rel = release(text)
236
+ if rel.runtime is None:
237
+ raise ValueError(f"no MATLAB Runtime version is known for {rel.name}")
238
+ return rel
239
+ if _FOLDER_RE.match(text):
240
+ for rel in releases():
241
+ if rel.runtime_folder and rel.runtime_folder.lower() == text.lower():
242
+ return rel
243
+ raise ValueError(f"unknown MATLAB Runtime folder: {ver!r}")
244
+ m = _DOTTED_RE.match(text)
245
+ if not m:
246
+ raise ValueError(f"not a MATLAB Runtime version like '9.14' or 'v914': {ver!r}")
247
+ full = m.group("version")
248
+ for candidate in (full, _two_part(full)):
249
+ for rel in releases():
250
+ if rel.runtime == candidate:
251
+ return rel
252
+ major, minor = (int(x) for x in _two_part(full).split("."))
253
+ if major >= 23 and minor in (1, 2) and _is_newer(2000 + major, "ab"[minor - 1]):
254
+ return _extrapolate(2000 + major, "ab"[minor - 1])
255
+ raise ValueError(f"unknown MATLAB Runtime version: {ver!r}")
256
+
257
+
258
+ def lookup(term: Union[int, str]) -> List[Tuple[str, Release]]:
259
+ """Every reading of ``term``, as ``(kind, release)`` pairs.
260
+
261
+ ``kind`` is ``"name"``, ``"matlab"``, ``"runtime"`` or ``"license"``. Some
262
+ terms have more than one reading: ``"9.0"`` is MATLAB R2016a but MATLAB
263
+ Runtime R2015b. An empty list means nothing matched.
264
+ """
265
+ text = str(term).strip()
266
+ if _NAME_RE.match(text):
267
+ try:
268
+ return [("name", release(text))]
269
+ except ValueError:
270
+ return []
271
+ if "." in text and not text.lower().startswith("v"):
272
+ readings = (("matlab", from_matlab), ("runtime", from_runtime))
273
+ elif _FOLDER_RE.match(text) and len(text.strip()) >= 4:
274
+ # three or more digits ("v914", "v901") can only be a Runtime folder
275
+ readings = (("runtime", from_runtime),)
276
+ else:
277
+ # "51", "v51", "v51.0" are licence versions; "v91" could also be a Runtime folder
278
+ readings = (("license", from_license), ("runtime", from_runtime))
279
+ found = []
280
+ for kind, fn in readings:
281
+ try:
282
+ found.append((kind, fn(text)))
283
+ except ValueError:
284
+ pass
285
+ return found
286
+
287
+
288
+ def parse_checkout(line: str, feature: Optional[str] = None) -> Optional[Checkout]:
289
+ """Parse one lmstat checkout line; ``None`` if it is not one."""
290
+ m = _CHECKOUT_RE.match(line.rstrip("\r\n"))
291
+ if not m:
292
+ return None
293
+ pid = m.group("pid")
294
+ return Checkout(
295
+ user=m.group("user"),
296
+ host=m.group("host"),
297
+ display=m.group("display") or None,
298
+ version=int(m.group("version")),
299
+ server=m.group("server"),
300
+ port=int(m.group("port")),
301
+ handle=int(m.group("handle")),
302
+ start=m.group("start"),
303
+ pid=int(pid) if pid else None,
304
+ feature=feature,
305
+ )
306
+
307
+
308
+ def parse_lmstat(text: Union[str, Iterable[str]]) -> Iterator[Checkout]:
309
+ """Yield every checkout in ``lmutil lmstat -a`` output, tagged with its feature."""
310
+ lines = text.splitlines() if isinstance(text, str) else text
311
+ feature = None
312
+ for line in lines:
313
+ m = _FEATURE_RE.match(line)
314
+ if m:
315
+ feature = m.group("feature")
316
+ continue
317
+ checkout = parse_checkout(line, feature)
318
+ if checkout is not None:
319
+ yield checkout
@@ -0,0 +1,84 @@
1
+ """Command line: ``matlab-versions R2024a 9.14 v51 v914``, ``lmstat -a | matlab-versions``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import sys
8
+ from collections import Counter
9
+ from typing import List, Optional
10
+
11
+ from . import Release, __version__, data, lookup, parse_lmstat, releases
12
+
13
+ _KIND = {"name": "release", "matlab": "MATLAB version", "runtime": "Runtime", "license": "licence version"}
14
+
15
+
16
+ def _describe(rel: Release) -> str:
17
+ def cell(value, key):
18
+ if value is None:
19
+ return "-"
20
+ return f"{value}*" if rel.evidence.get(key) == "extrapolated" else str(value)
21
+
22
+ runtime = cell(rel.runtime, "runtime")
23
+ if rel.runtime_folder:
24
+ runtime += f" ({rel.runtime_folder})"
25
+ license = cell(f"v{rel.license}" if rel.license is not None else None, "license")
26
+ return f"{rel.name}\tmatlab {cell(rel.matlab, 'matlab')}\truntime {runtime}\tlicense {license}"
27
+
28
+
29
+ def main(argv: Optional[List[str]] = None) -> int:
30
+ parser = argparse.ArgumentParser(
31
+ prog="matlab-versions",
32
+ description="Translate between MATLAB release names (R2024a), MATLAB versions (24.1), "
33
+ "MATLAB Runtime versions and folders (9.14, v914) and FlexLM licence versions (lmstat '(v51)'). "
34
+ "With no arguments, reads 'lmutil lmstat -a' output from stdin and lists each checkout's release. "
35
+ "Values marked * are extrapolated.",
36
+ )
37
+ parser.add_argument("terms", nargs="*", metavar="TERM", help="e.g. R2024a, 24.1, 9.14, v914, v51, 51")
38
+ group = parser.add_mutually_exclusive_group()
39
+ group.add_argument("--table", action="store_true", help="print the bundled table")
40
+ group.add_argument("--json", action="store_true", help="print the bundled table as JSON, with sources")
41
+ parser.add_argument("--summary", action="store_true", help="with stdin: count checkouts per feature and release")
42
+ parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
43
+ args = parser.parse_args(argv)
44
+
45
+ if args.json:
46
+ json.dump(data(), sys.stdout, indent=2, ensure_ascii=False)
47
+ print()
48
+ return 0
49
+ if args.table:
50
+ for rel in releases():
51
+ print(_describe(rel))
52
+ return 0
53
+ if args.terms:
54
+ status = 0
55
+ for term in args.terms:
56
+ matches = lookup(term)
57
+ if not matches:
58
+ print(f"matlab-versions: nothing matches {term!r}", file=sys.stderr)
59
+ status = 2
60
+ # "9.14" reads as both MATLAB and Runtime version of R2023a: one line, both kinds
61
+ kinds_by_release = {}
62
+ for kind, rel in matches:
63
+ kinds_by_release.setdefault(rel, []).append(_KIND[kind])
64
+ for rel, kinds in kinds_by_release.items():
65
+ print(f"{term} ({', '.join(kinds)})\t{_describe(rel)}")
66
+ return status
67
+
68
+ if sys.stdin.isatty():
69
+ parser.print_usage(sys.stderr)
70
+ return 2
71
+ checkouts = list(parse_lmstat(sys.stdin))
72
+ if args.summary:
73
+ counts = Counter((c.feature or "?", c.release.name if c.release else f"v{c.version}") for c in checkouts)
74
+ for (feature, name), n in sorted(counts.items()):
75
+ print(f"{feature}\t{name}\t{n}")
76
+ else:
77
+ for c in checkouts:
78
+ name = c.release.name if c.release else "?"
79
+ print(f"{c.feature or '?'}\t{c.user}\t{c.host}\tv{c.version}\t{name}")
80
+ return 0
81
+
82
+
83
+ if __name__ == "__main__":
84
+ sys.exit(main())
File without changes