diskinfo 4.1.2__tar.gz → 4.2.0__tar.gz

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.
Files changed (25) hide show
  1. {diskinfo-4.1.2/src/diskinfo.egg-info → diskinfo-4.2.0}/PKG-INFO +5 -5
  2. {diskinfo-4.1.2 → diskinfo-4.2.0}/README.md +1 -1
  3. {diskinfo-4.1.2 → diskinfo-4.2.0}/pyproject.toml +17 -11
  4. {diskinfo-4.1.2 → diskinfo-4.2.0}/src/diskinfo/demo.py +3 -2
  5. {diskinfo-4.1.2 → diskinfo-4.2.0}/src/diskinfo/disk.py +2 -1
  6. {diskinfo-4.1.2 → diskinfo-4.2.0}/src/diskinfo/filesystem.py +30 -7
  7. {diskinfo-4.1.2 → diskinfo-4.2.0}/src/diskinfo/partition.py +3 -2
  8. {diskinfo-4.1.2 → diskinfo-4.2.0}/src/diskinfo/utils.py +60 -4
  9. {diskinfo-4.1.2 → diskinfo-4.2.0/src/diskinfo.egg-info}/PKG-INFO +5 -5
  10. {diskinfo-4.1.2 → diskinfo-4.2.0}/test/test_filesystem.py +68 -1
  11. {diskinfo-4.1.2 → diskinfo-4.2.0}/test/test_partition.py +35 -0
  12. {diskinfo-4.1.2 → diskinfo-4.2.0}/test/test_utils.py +85 -4
  13. {diskinfo-4.1.2 → diskinfo-4.2.0}/LICENSE +0 -0
  14. {diskinfo-4.1.2 → diskinfo-4.2.0}/setup.cfg +0 -0
  15. {diskinfo-4.1.2 → diskinfo-4.2.0}/src/diskinfo/__init__.py +0 -0
  16. {diskinfo-4.1.2 → diskinfo-4.2.0}/src/diskinfo/diskinfo.py +0 -0
  17. {diskinfo-4.1.2 → diskinfo-4.2.0}/src/diskinfo/disksmart.py +0 -0
  18. {diskinfo-4.1.2 → diskinfo-4.2.0}/src/diskinfo/disktype.py +0 -0
  19. {diskinfo-4.1.2 → diskinfo-4.2.0}/src/diskinfo.egg-info/SOURCES.txt +0 -0
  20. {diskinfo-4.1.2 → diskinfo-4.2.0}/src/diskinfo.egg-info/dependency_links.txt +0 -0
  21. {diskinfo-4.1.2 → diskinfo-4.2.0}/src/diskinfo.egg-info/requires.txt +0 -0
  22. {diskinfo-4.1.2 → diskinfo-4.2.0}/src/diskinfo.egg-info/top_level.txt +0 -0
  23. {diskinfo-4.1.2 → diskinfo-4.2.0}/test/test_disk.py +0 -0
  24. {diskinfo-4.1.2 → diskinfo-4.2.0}/test/test_diskinfo.py +0 -0
  25. {diskinfo-4.1.2 → diskinfo-4.2.0}/test/test_disksmart.py +0 -0
@@ -1,23 +1,23 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: diskinfo
3
- Version: 4.1.2
3
+ Version: 4.2.0
4
4
  Summary: Disk information Python library for Linux
5
5
  Author-email: Peter Sulyok <peter@sulyok.net>
6
6
  License-Expression: MIT
7
7
  Project-URL: Homepage, https://github.com/petersulyok/diskinfo
8
- Project-URL: Changelog, https://github.com/petersulyok/diskinfo/CHANGELOG.md
8
+ Project-URL: Changelog, https://github.com/petersulyok/diskinfo/blob/main/CHANGELOG.md
9
9
  Project-URL: Documentation, https://diskinfo.readthedocs.io/en/latest/index.html
10
10
  Project-URL: Issues, https://github.com/petersulyok/diskinfo/issues
11
11
  Keywords: disk,linux
12
- Classifier: Programming Language :: Python :: 3.10
13
12
  Classifier: Programming Language :: Python :: 3.11
14
13
  Classifier: Programming Language :: Python :: 3.12
15
14
  Classifier: Programming Language :: Python :: 3.13
16
15
  Classifier: Programming Language :: Python :: 3.14
16
+ Classifier: Programming Language :: Python :: 3.15
17
17
  Classifier: Operating System :: POSIX :: Linux
18
18
  Classifier: Topic :: System :: Hardware
19
19
  Classifier: Development Status :: 5 - Production/Stable
20
- Requires-Python: <4,>=3.10
20
+ Requires-Python: <4,>=3.11
21
21
  Description-Content-Type: text/markdown
22
22
  License-File: LICENSE
23
23
  Requires-Dist: pySMART
@@ -25,7 +25,7 @@ Requires-Dist: pyudev
25
25
  Dynamic: license-file
26
26
 
27
27
  # diskinfo
28
- [![Tests](https://github.com/petersulyok/smfc/actions/workflows/test.yml/badge.svg)](https://github.com/petersulyok/diskinfo/actions/workflows/tests.yml)
28
+ [![Tests](https://github.com/petersulyok/diskinfo/actions/workflows/test.yml/badge.svg)](https://github.com/petersulyok/diskinfo/actions/workflows/test.yml)
29
29
  [![Codecov](https://codecov.io/gh/petersulyok/diskinfo/branch/main/graph/badge.svg)](https://app.codecov.io/gh/petersulyok/diskinfo)
30
30
  [![Documentation Status](https://readthedocs.org/projects/diskinfo/badge/?version=latest)](https://diskinfo.readthedocs.io/en/latest/?badge=latest)
31
31
  [![Issues](https://img.shields.io/github/issues/petersulyok/diskinfo)](https://github.com/petersulyok/diskinfo/issues)
@@ -1,5 +1,5 @@
1
1
  # diskinfo
2
- [![Tests](https://github.com/petersulyok/smfc/actions/workflows/test.yml/badge.svg)](https://github.com/petersulyok/diskinfo/actions/workflows/tests.yml)
2
+ [![Tests](https://github.com/petersulyok/diskinfo/actions/workflows/test.yml/badge.svg)](https://github.com/petersulyok/diskinfo/actions/workflows/test.yml)
3
3
  [![Codecov](https://codecov.io/gh/petersulyok/diskinfo/branch/main/graph/badge.svg)](https://app.codecov.io/gh/petersulyok/diskinfo)
4
4
  [![Documentation Status](https://readthedocs.org/projects/diskinfo/badge/?version=latest)](https://diskinfo.readthedocs.io/en/latest/?badge=latest)
5
5
  [![Issues](https://img.shields.io/github/issues/petersulyok/diskinfo)](https://github.com/petersulyok/diskinfo/issues)
@@ -1,21 +1,21 @@
1
1
  [project]
2
2
  name = "diskinfo"
3
3
  description = "Disk information Python library for Linux"
4
- version = "4.1.2"
4
+ version = "4.2.0"
5
5
  readme = "README.md"
6
6
  authors = [
7
7
  { name = "Peter Sulyok", email = "peter@sulyok.net" }
8
8
  ]
9
- requires-python = ">=3.10, <4"
9
+ requires-python = ">=3.11, <4"
10
10
  keywords = ["disk", "linux"]
11
11
  license= "MIT"
12
12
  license-files= ["LICENSE"]
13
13
  classifiers = [
14
- "Programming Language :: Python :: 3.10",
15
14
  "Programming Language :: Python :: 3.11",
16
15
  "Programming Language :: Python :: 3.12",
17
16
  "Programming Language :: Python :: 3.13",
18
17
  "Programming Language :: Python :: 3.14",
18
+ "Programming Language :: Python :: 3.15",
19
19
  "Operating System :: POSIX :: Linux",
20
20
  "Topic :: System :: Hardware",
21
21
  "Development Status :: 5 - Production/Stable"
@@ -29,10 +29,8 @@ dependencies = [
29
29
  dev = [
30
30
  "build",
31
31
  "coverage",
32
- "mock",
33
32
  "pytest",
34
33
  "pytest-cov",
35
- "pytest-mock",
36
34
  "twine"
37
35
  ]
38
36
  lint = [
@@ -55,7 +53,7 @@ build-backend = "setuptools.build_meta"
55
53
 
56
54
  [project.urls]
57
55
  Homepage = "https://github.com/petersulyok/diskinfo"
58
- Changelog = "https://github.com/petersulyok/diskinfo/CHANGELOG.md"
56
+ Changelog = "https://github.com/petersulyok/diskinfo/blob/main/CHANGELOG.md"
59
57
  Documentation = "https://diskinfo.readthedocs.io/en/latest/index.html"
60
58
  Issues = "https://github.com/petersulyok/diskinfo/issues"
61
59
 
@@ -73,19 +71,27 @@ filterwarnings = [
73
71
  ]
74
72
 
75
73
  # coverage options
74
+ [tool.coverage.run]
75
+ source = ["src/diskinfo"]
76
+ omit = [
77
+ "src/diskinfo/demo.py"
78
+ ]
79
+
76
80
  [tool.coverage.report]
81
+ fail_under = 100
82
+ show_missing = true
83
+ skip_covered = false
77
84
  exclude_lines = [
78
85
  "pragma: no cover",
79
86
  "if __name__ == .__main__.:",
80
- "unittest.main()"
81
- ]
82
- omit = [
83
- "src/diskinfo/demo.py"
87
+ "unittest.main()",
88
+ "raise NotImplementedError",
89
+ "if TYPE_CHECKING:"
84
90
  ]
85
91
 
86
92
  # ruff options
87
93
  [tool.ruff]
88
- target-version = "py310"
94
+ target-version = "py311"
89
95
  line-length = 120
90
96
 
91
97
  [tool.ruff.lint]
@@ -227,7 +227,8 @@ def partition_demo(name: str):
227
227
  table.add_column("Type", justify="left", style="bold orchid")
228
228
  table.add_column("Start", justify="right", style="bold gray54")
229
229
  table.add_column("Size", justify="right", style="bold gray54")
230
- table.add_column("Label", justify="left", style="bold purple3")
230
+ table.add_column("Part label", justify="left", style="bold sky_blue3")
231
+ table.add_column("FS label", justify="left", style="bold purple3")
231
232
  table.add_column("Mounting point", justify="left", style="bold green")
232
233
  table.add_column("Free", justify="right", style="bold blue")
233
234
  for p in plist:
@@ -237,7 +238,7 @@ def partition_demo(name: str):
237
238
  else:
238
239
  free_size_str = " - %"
239
240
  table.add_row(p.get_name(), fs.get_fs_type(), str(p.get_part_offset()), str(p.get_part_size()),
240
- fs.get_fs_label(), fs.get_fs_mounting_point(), free_size_str)
241
+ p.get_part_label(), fs.get_fs_label(), fs.get_fs_mounting_point(), free_size_str)
241
242
  group = Group(panel, table)
242
243
 
243
244
  rprint(Panel(group, title="[markdown.strong]diskinfo demo: partitions[/]", title_align="left",
@@ -307,7 +307,8 @@ class Disk:
307
307
  return self.__wwn
308
308
 
309
309
  def get_model(self) -> str:
310
- """Returns the disk model string.
310
+ """Returns the disk model string. The escape sequences of the udev property (e.g. ``\\x20`` for a space
311
+ character) are decoded in the result.
311
312
 
312
313
  Example:
313
314
  An example about the use of this function::
@@ -3,6 +3,7 @@
3
3
  # Peter Sulyok (C) 2022-2026.
4
4
  #
5
5
  import os
6
+ import re
6
7
  from typing import Tuple
7
8
  from pyudev import Device
8
9
  from diskinfo.utils import size_in_hrf, _pyudev_getenc
@@ -10,6 +11,24 @@ from diskinfo.utils import size_in_hrf, _pyudev_getenc
10
11
  # Path to the kernel mount table.
11
12
  _PROC_MOUNTS = "/proc/mounts"
12
13
 
14
+ # Octal escape sequence used by the kernel in the mount table (e.g. `\\040` for a space character).
15
+ _MOUNT_ESCAPE_RE = re.compile(r"\\([0-7]{3})")
16
+
17
+
18
+ def _unmangle_path(path: str) -> str:
19
+ """Decodes the octal escape sequences of a path in the kernel mount table. The kernel escapes the space,
20
+ tab, newline and backslash characters (i.e. ``\\040``, ``\\011``, ``\\012`` and ``\\134``) in the device node and
21
+ in the mounting point fields of ``/proc/mounts``.
22
+
23
+ Args:
24
+ path (str): path with escape sequences
25
+
26
+ Returns:
27
+ str: decoded path
28
+
29
+ """
30
+ return _MOUNT_ESCAPE_RE.sub(lambda m: chr(int(m.group(1), 8)), path)
31
+
13
32
 
14
33
  class FileSystem:
15
34
  """This class encapsulates filesystem-related information read from a block device's udev properties.
@@ -19,7 +38,8 @@ class FileSystem:
19
38
 
20
39
  1. The mounting point and available space are determined by reading ``/proc/mounts`` and calling
21
40
  :func:`os.statvfs`. If the file system is not mounted, the mounting point will be empty and the
22
- free size will be ``0``.
41
+ free size will be ``0``. The octal escape sequences of the kernel mount table (e.g. ``\\040`` for a
42
+ space character) are decoded.
23
43
  2. Instances are created internally by :class:`~diskinfo.Partition` and :class:`~diskinfo.Disk`.
24
44
 
25
45
  Args:
@@ -55,10 +75,10 @@ class FileSystem:
55
75
  parts = line.split()
56
76
  if len(parts) < 2:
57
77
  continue
58
- if parts[0] == path:
59
- self.__fs_mounting_point = parts[1]
78
+ if _unmangle_path(parts[0]) == path:
79
+ self.__fs_mounting_point = _unmangle_path(parts[1])
60
80
  try:
61
- st = os.statvfs(parts[1])
81
+ st = os.statvfs(self.__fs_mounting_point)
62
82
  self.__fs_size = (st.f_blocks * st.f_frsize) // 512
63
83
  self.__fs_free_size = (st.f_bavail * st.f_frsize) // 512
64
84
  except OSError:
@@ -75,13 +95,15 @@ class FileSystem:
75
95
  pass
76
96
 
77
97
  def get_fs_label(self) -> str:
78
- """Returns the label of the file system. The result could be empty if the file system does not have a label.
98
+ """Returns the label of the file system. The result could be empty if the file system does not have a
99
+ label. The escape sequences of the udev property (e.g. ``\\xc3\\x89`` for the ``É`` character) are decoded
100
+ in the result.
79
101
  """
80
102
  return self.__fs_label
81
103
 
82
104
  def get_fs_uuid(self) -> str:
83
105
  """Returns the UUID of the file system. The result could be empty if the device does not contain a
84
- file system.
106
+ file system. The escape sequences of the udev property are decoded in the result.
85
107
  """
86
108
  return self.__fs_uuid
87
109
 
@@ -155,7 +177,8 @@ class FileSystem:
155
177
 
156
178
  def get_fs_mounting_point(self) -> str:
157
179
  """Returns the mounting point of the file system. The result could be empty if the device does not
158
- contain any file system, or it is not mounted.
180
+ contain any file system, or it is not mounted. The octal escape sequences of the kernel mount table
181
+ (e.g. ``\\040`` for a space character) are decoded in the result.
159
182
  """
160
183
  return self.__fs_mounting_point
161
184
 
@@ -4,7 +4,7 @@
4
4
  #
5
5
  from typing import List, Tuple
6
6
  from pyudev import Device
7
- from diskinfo.utils import size_in_hrf, _pyudev_getint
7
+ from diskinfo.utils import size_in_hrf, _pyudev_getint, _pyudev_getesc
8
8
  from diskinfo.filesystem import FileSystem
9
9
 
10
10
 
@@ -96,7 +96,7 @@ class Partition:
96
96
 
97
97
  # Save further partition attributes.
98
98
  self.__part_scheme = _device.get("ID_PART_ENTRY_SCHEME")
99
- self.__part_label = _device.get("ID_PART_ENTRY_NAME")
99
+ self.__part_label = _pyudev_getesc(_device, "ID_PART_ENTRY_NAME")
100
100
  self.__part_uuid = _device.get("ID_PART_ENTRY_UUID")
101
101
  self.__part_type = _device.get("ID_PART_ENTRY_TYPE")
102
102
  self.__part_number = _pyudev_getint(_device, "ID_PART_ENTRY_NUMBER", 0)
@@ -347,6 +347,7 @@ class Partition:
347
347
 
348
348
  def get_part_label(self) -> str:
349
349
  """Returns the label of the partition. The result could be empty if the partition does not have a label.
350
+ The escape sequences of the udev property (e.g. ``\\x20`` for a space character) are decoded in the result.
350
351
 
351
352
  .. note::
352
353
  This is a GPT partition-specific value.
@@ -2,9 +2,36 @@
2
2
  # Module `utils`: implements utility functions.
3
3
  # Peter Sulyok (C) 2022-2026.
4
4
  #
5
+ import re
5
6
  from typing import List, Tuple, Union
6
7
  from pyudev import Device
7
8
 
9
+ # Escape sequences of an udev `_ENC` property value (e.g. `\\x20` for a space character). One non-ASCII
10
+ # character is stored as several consecutive sequences, one per UTF-8 byte, so they are matched in groups.
11
+ _UDEV_ESCAPE_RE = re.compile(r"(?:\\x[0-9a-fA-F]{2})+")
12
+
13
+
14
+ def _unescape_udev_value(value: str) -> str:
15
+ """Decodes the escape sequences of an udev ``_ENC`` property value. The udev escapes all bytes outside its
16
+ safe character set in the form of ``\\xNN``, where ``NN`` is the hexadecimal value of the byte. The escaped
17
+ bytes of a group are decoded together as an UTF-8 character.
18
+
19
+ Args:
20
+ value (str): property value with escape sequences
21
+
22
+ Returns:
23
+ str: decoded property value
24
+
25
+ Example:
26
+ An example about the use of the function::
27
+
28
+ >>> from diskinfo import *
29
+ >>> _unescape_udev_value("CL\\xc3\\x89\\x20USB")
30
+ "CLÉ USB"
31
+
32
+ """
33
+ return _UDEV_ESCAPE_RE.sub(lambda m: bytes.fromhex(m.group(0).replace("\\x", "")).decode("utf-8", "replace"), value)
34
+
8
35
 
9
36
  def _read_file(path, encoding: str = "utf-8") -> str:
10
37
  """Reads the text content of the specified file. The function will hide :py:obj:`IOError` and
@@ -215,10 +242,40 @@ def _pyudev_getint(device: Device, key: str, default_value: int = None) -> Union
215
242
  return default_value
216
243
 
217
244
 
245
+ def _pyudev_getesc(dev: Device, key: str) -> Union[str, None]:
246
+ """Returns a device attribute value where udev stores the escaped value in the property itself, so there is
247
+ no `_ENC` key pair (e.g. `ID_PART_ENTRY_NAME`). The escape sequences of the value will be decoded and the
248
+ result will be stripped.
249
+
250
+ Args:
251
+ device (pyudev.Device): pyudev.Device() class
252
+ key (str): attribute key
253
+
254
+ Returns:
255
+ Union[str, None]: str attribute value or None
256
+
257
+ Example:
258
+ An example about the use of the function::
259
+
260
+ >>> from diskinfo import *
261
+ >>> from pyudev import *
262
+ >>> c = Context()
263
+ >>> d = Devices.from_name(c, "block", "sda1")
264
+ >>> print(_pyudev_getesc(d, "ID_PART_ENTRY_NAME"))
265
+ Basic data partition
266
+
267
+ """
268
+ value = dev.get(key)
269
+ if value:
270
+ return _unescape_udev_value(value).strip()
271
+ return value
272
+
273
+
218
274
  def _pyudev_getenc(dev: Device, key: str) -> Union[str, None]:
219
275
  """Returns one of the device attribute key pairs. There are udev attributes key pairs (e.g. `ID_MODEL` and
220
- `ID_MODEL_ENC`), where this function first tries to read and decode the `_ENC` version of the key then the simple
221
- key if the previous one does not exist.
276
+ `ID_MODEL_ENC`), where this function reads and decodes the `_ENC` version of the key if it exists, otherwise
277
+ the simple key will be read. The `_ENC` version is always preferred, because the simple key contains the
278
+ sanitized value of udev (e.g. the space characters are replaced there).
222
279
 
223
280
  Args:
224
281
  device (pyudev.Device): pyudev.Device() class
@@ -243,8 +300,7 @@ def _pyudev_getenc(dev: Device, key: str) -> Union[str, None]:
243
300
  # Read and decode the `_ENC` key.
244
301
  value = dev.get(key + "_ENC")
245
302
  if value:
246
- if "\\x20" in value:
247
- return value.replace("\\x20", " ").strip()
303
+ return _unescape_udev_value(value).strip()
248
304
  # Read the simple key.
249
305
  return dev.get(key)
250
306
 
@@ -1,23 +1,23 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: diskinfo
3
- Version: 4.1.2
3
+ Version: 4.2.0
4
4
  Summary: Disk information Python library for Linux
5
5
  Author-email: Peter Sulyok <peter@sulyok.net>
6
6
  License-Expression: MIT
7
7
  Project-URL: Homepage, https://github.com/petersulyok/diskinfo
8
- Project-URL: Changelog, https://github.com/petersulyok/diskinfo/CHANGELOG.md
8
+ Project-URL: Changelog, https://github.com/petersulyok/diskinfo/blob/main/CHANGELOG.md
9
9
  Project-URL: Documentation, https://diskinfo.readthedocs.io/en/latest/index.html
10
10
  Project-URL: Issues, https://github.com/petersulyok/diskinfo/issues
11
11
  Keywords: disk,linux
12
- Classifier: Programming Language :: Python :: 3.10
13
12
  Classifier: Programming Language :: Python :: 3.11
14
13
  Classifier: Programming Language :: Python :: 3.12
15
14
  Classifier: Programming Language :: Python :: 3.13
16
15
  Classifier: Programming Language :: Python :: 3.14
16
+ Classifier: Programming Language :: Python :: 3.15
17
17
  Classifier: Operating System :: POSIX :: Linux
18
18
  Classifier: Topic :: System :: Hardware
19
19
  Classifier: Development Status :: 5 - Production/Stable
20
- Requires-Python: <4,>=3.10
20
+ Requires-Python: <4,>=3.11
21
21
  Description-Content-Type: text/markdown
22
22
  License-File: LICENSE
23
23
  Requires-Dist: pySMART
@@ -25,7 +25,7 @@ Requires-Dist: pyudev
25
25
  Dynamic: license-file
26
26
 
27
27
  # diskinfo
28
- [![Tests](https://github.com/petersulyok/smfc/actions/workflows/test.yml/badge.svg)](https://github.com/petersulyok/diskinfo/actions/workflows/tests.yml)
28
+ [![Tests](https://github.com/petersulyok/diskinfo/actions/workflows/test.yml/badge.svg)](https://github.com/petersulyok/diskinfo/actions/workflows/test.yml)
29
29
  [![Codecov](https://codecov.io/gh/petersulyok/diskinfo/branch/main/graph/badge.svg)](https://app.codecov.io/gh/petersulyok/diskinfo)
30
30
  [![Documentation Status](https://readthedocs.org/projects/diskinfo/badge/?version=latest)](https://diskinfo.readthedocs.io/en/latest/?badge=latest)
31
31
  [![Issues](https://img.shields.io/github/issues/petersulyok/diskinfo)](https://github.com/petersulyok/diskinfo/issues)
@@ -5,7 +5,7 @@
5
5
  import os
6
6
  from unittest.mock import MagicMock, patch, mock_open
7
7
  import pytest
8
- from diskinfo.filesystem import FileSystem
8
+ from diskinfo.filesystem import FileSystem, _unmangle_path
9
9
 
10
10
 
11
11
  # ── mock helpers ──────────────────────────────────────────────────────────────
@@ -270,4 +270,71 @@ def test_filesystem_repr():
270
270
  assert "/mnt" in result
271
271
 
272
272
 
273
+ # ── _unmangle_path ────────────────────────────────────────────────────────
274
+
275
+
276
+ @pytest.mark.parametrize(
277
+ "mangled, expected",
278
+ [
279
+ ("/mnt/data", "/mnt/data"),
280
+ ("", ""),
281
+ ("/run/media/user/Laptop\\040Files", "/run/media/user/Laptop Files"),
282
+ ("/mnt/tab\\011here", "/mnt/tab\there"),
283
+ ("/mnt/new\\012line", "/mnt/new\nline"),
284
+ ("/mnt/back\\134slash", "/mnt/back\\slash"),
285
+ ("/mnt/a\\040b\\040c", "/mnt/a b c"),
286
+ ("/mnt/mixed\\040and\\134escaped", "/mnt/mixed and\\escaped"),
287
+ ],
288
+ )
289
+ def test_unmangle_path_decodes_octal_escapes(mangled, expected):
290
+ """_unmangle_path() decodes the octal escape sequences of the kernel mount table."""
291
+ assert _unmangle_path(mangled) == expected
292
+
293
+
294
+ def test_unmangle_path_does_not_double_decode():
295
+ """_unmangle_path() decodes an escaped backslash without decoding the text that follows it."""
296
+ # `\134040` is an escaped backslash followed by the literal text `040`.
297
+ assert _unmangle_path("/mnt/x\\134040") == "/mnt/x\\040"
298
+
299
+
300
+ # ── FileSystem.__init__ – mangled paths in /proc/mounts ───────────────────────
301
+
302
+
303
+ def test_filesystem_unmangles_mount_point_with_space():
304
+ """FileSystem decodes \\040 in the mounting point and calls statvfs() with the decoded path."""
305
+ dev = _make_fs_device(path="/dev/sdc4")
306
+ m = _proc_mounts(("/dev/sdc4", "/run/media/pchauhan/Laptop\\040Files"))
307
+ with (patch("builtins.open", m),
308
+ patch("os.statvfs", return_value=_statvfs_result(f_bavail=2048, f_frsize=512)) as mock_statvfs):
309
+ fs = FileSystem(dev)
310
+
311
+ assert fs.get_fs_mounting_point() == "/run/media/pchauhan/Laptop Files"
312
+ mock_statvfs.assert_called_once_with("/run/media/pchauhan/Laptop Files")
313
+ assert fs.get_fs_free_size() == 2048
314
+
315
+
316
+ def test_filesystem_unmangles_mount_point_with_tab_and_backslash():
317
+ """FileSystem decodes the tab and backslash escape sequences in the mounting point."""
318
+ dev = _make_fs_device(path="/dev/sdb1")
319
+ m = _proc_mounts(("/dev/sdb1", "/mnt/a\\011b\\134c"))
320
+ with (patch("builtins.open", m),
321
+ patch("os.statvfs", return_value=_statvfs_result(f_bavail=100, f_frsize=512)) as mock_statvfs):
322
+ fs = FileSystem(dev)
323
+
324
+ assert fs.get_fs_mounting_point() == "/mnt/a\tb\\c"
325
+ mock_statvfs.assert_called_once_with("/mnt/a\tb\\c")
326
+
327
+
328
+ def test_filesystem_unmangles_device_node():
329
+ """FileSystem matches a device node that is escaped in /proc/mounts."""
330
+ dev = _make_fs_device(path="/dev/disk/by-label/My Disk")
331
+ m = _proc_mounts(("/dev/disk/by-label/My\\040Disk", "/mnt/data"))
332
+ with (patch("builtins.open", m),
333
+ patch("os.statvfs", return_value=_statvfs_result(f_bavail=64, f_frsize=512))):
334
+ fs = FileSystem(dev)
335
+
336
+ assert fs.get_fs_mounting_point() == "/mnt/data"
337
+ assert fs.get_fs_free_size() == 64
338
+
339
+
273
340
  # End
@@ -373,4 +373,39 @@ def test_partition_repr():
373
373
  assert "filesystem=" in result
374
374
 
375
375
 
376
+ # ── Partition – udev escape sequences in the partition label ─────────────────
377
+
378
+
379
+ @pytest.mark.parametrize(
380
+ "escaped, expected",
381
+ [
382
+ ("EFI\\x20system\\x20partition", "EFI system partition"),
383
+ ("Basic\\x20data\\x20partition", "Basic data partition"),
384
+ ("Microsoft\\x20reserved\\x20partition", "Microsoft reserved partition"),
385
+ ("Boot", "Boot"),
386
+ ("Donn\\xc3\\xa9es", "Données"),
387
+ ],
388
+ )
389
+ def test_partition_decodes_part_label(escaped, expected):
390
+ """Partition decodes the udev escape sequences of the partition label (issue #10)."""
391
+ dev = _make_part_device(part_label=escaped)
392
+ m = _proc_mounts(("/dev/sda1", "/boot/efi"))
393
+ with (patch("builtins.open", m),
394
+ patch("os.statvfs", return_value=_statvfs_result(f_bavail=512000))):
395
+ p = Partition(dev)
396
+
397
+ assert p.get_part_label() == expected
398
+
399
+
400
+ def test_partition_missing_part_label_stays_none():
401
+ """Partition keeps the missing partition label as None instead of raising an exception."""
402
+ dev = _make_part_device(part_label=None)
403
+ m = _proc_mounts(("/dev/sda1", "/boot/efi"))
404
+ with (patch("builtins.open", m),
405
+ patch("os.statvfs", return_value=_statvfs_result(f_bavail=512000))):
406
+ p = Partition(dev)
407
+
408
+ assert p.get_part_label() is None
409
+
410
+
376
411
  # End
@@ -12,6 +12,8 @@ from diskinfo.utils import (
12
12
  time_in_hrf,
13
13
  _pyudev_getint,
14
14
  _pyudev_getenc,
15
+ _unescape_udev_value,
16
+ _pyudev_getesc,
15
17
  )
16
18
 
17
19
 
@@ -263,13 +265,29 @@ def test_pyudev_getenc_falls_back_when_enc_is_none():
263
265
  assert _pyudev_getenc(dev, "ID_MODEL") == "Samsung_SSD_850"
264
266
 
265
267
 
266
- def test_pyudev_getenc_enc_without_x20_falls_back_to_plain_key():
267
- """_pyudev_getenc() uses the plain key when _ENC exists but has no \\x20."""
268
+ def test_pyudev_getenc_prefers_enc_key_without_escapes():
269
+ """_pyudev_getenc() uses the _ENC key even if it does not contain any escape sequence."""
268
270
  dev = MagicMock()
269
271
  props = {"ID_MODEL_ENC": "SamsungSSD850", "ID_MODEL": "SamsungSSD850_plain"}
270
272
  dev.get.side_effect = props.get
271
- # ENC has no \x20, so falls through to plain key
272
- assert _pyudev_getenc(dev, "ID_MODEL") == "SamsungSSD850_plain"
273
+ # The _ENC key is the unsanitized value, so it wins over the plain key (issue #9).
274
+ assert _pyudev_getenc(dev, "ID_MODEL") == "SamsungSSD850"
275
+
276
+
277
+ def test_pyudev_getenc_decodes_non_ascii_with_space():
278
+ """_pyudev_getenc() decodes a non-ASCII character and a space in the same value (issue #9)."""
279
+ dev = MagicMock()
280
+ props = {"ID_FS_LABEL_ENC": "CL\\xc3\\x89\\x20USB", "ID_FS_LABEL": "CLÉ_USB"}
281
+ dev.get.side_effect = props.get
282
+ assert _pyudev_getenc(dev, "ID_FS_LABEL") == "CLÉ USB"
283
+
284
+
285
+ def test_pyudev_getenc_decodes_non_ascii_without_space():
286
+ """_pyudev_getenc() decodes a non-ASCII character when the value has no space (issue #9)."""
287
+ dev = MagicMock()
288
+ props = {"ID_FS_LABEL_ENC": "CL\\xc3\\x89USB", "ID_FS_LABEL": "CL_USB"}
289
+ dev.get.side_effect = props.get
290
+ assert _pyudev_getenc(dev, "ID_FS_LABEL") == "CLÉUSB"
273
291
 
274
292
 
275
293
  def test_pyudev_getenc_returns_none_when_both_keys_absent():
@@ -287,4 +305,67 @@ def test_pyudev_getenc_strips_whitespace_after_decode():
287
305
  assert _pyudev_getenc(dev, "ID_FS_LABEL") == "My Label"
288
306
 
289
307
 
308
+ # ── _unescape_udev_value ──────────────────────────────────────────────────────
309
+
310
+
311
+ @pytest.mark.parametrize(
312
+ "escaped, expected",
313
+ [
314
+ ("SYSTEM", "SYSTEM"),
315
+ ("", ""),
316
+ ("Recovery\\x20tools", "Recovery tools"),
317
+ ("Samsung\\x20SSD\\x20850", "Samsung SSD 850"),
318
+ ("CL\\xc3\\x89\\x20USB", "CLÉ USB"),
319
+ ("CL\\xc3\\x89USB", "CLÉUSB"),
320
+ ("caf\\xc3\\xa9\\x20dr\\xc3\\xafve", "café drïve"),
321
+ ("\\xe2\\x82\\xac", "€"),
322
+ ("\\xC3\\x89", "É"),
323
+ ],
324
+ )
325
+ def test_unescape_udev_value_decodes_escapes(escaped, expected):
326
+ """_unescape_udev_value() decodes the udev escape sequences into UTF-8 characters."""
327
+ assert _unescape_udev_value(escaped) == expected
328
+
329
+
330
+ def test_unescape_udev_value_invalid_utf8_is_replaced():
331
+ """_unescape_udev_value() replaces an invalid UTF-8 byte instead of raising an exception."""
332
+ assert _unescape_udev_value("bad\\xffend") == "bad\ufffdend"
333
+
334
+
335
+ def test_unescape_udev_value_keeps_non_escape_text():
336
+ """_unescape_udev_value() keeps a backslash that is not part of an escape sequence."""
337
+ assert _unescape_udev_value("a\\xzz\\x41") == "a\\xzzA"
338
+
339
+
340
+ # ── _pyudev_getesc ────────────────────────────────────────────────────────────
341
+
342
+
343
+ def test_pyudev_getesc_decodes_escapes():
344
+ """_pyudev_getesc() decodes the escape sequences stored in the property value itself."""
345
+ dev = MagicMock()
346
+ dev.get.side_effect = {"ID_PART_ENTRY_NAME": "Basic\\x20data\\x20partition"}.get
347
+ assert _pyudev_getesc(dev, "ID_PART_ENTRY_NAME") == "Basic data partition"
348
+
349
+
350
+ def test_pyudev_getesc_strips_whitespace_after_decode():
351
+ """_pyudev_getesc() strips the leading and trailing whitespaces after the decoding."""
352
+ dev = MagicMock()
353
+ dev.get.side_effect = {"ID_PART_ENTRY_NAME": "\\x20Boot\\x20"}.get
354
+ assert _pyudev_getesc(dev, "ID_PART_ENTRY_NAME") == "Boot"
355
+
356
+
357
+ def test_pyudev_getesc_missing_key_returns_none():
358
+ """_pyudev_getesc() returns None if the key does not exist."""
359
+ dev = MagicMock()
360
+ dev.get.return_value = None
361
+ assert _pyudev_getesc(dev, "ID_PART_ENTRY_NAME") is None
362
+
363
+
364
+ def test_pyudev_getesc_empty_value_is_kept():
365
+ """_pyudev_getesc() returns the empty value without decoding."""
366
+ dev = MagicMock()
367
+ dev.get.return_value = ""
368
+ assert _pyudev_getesc(dev, "ID_PART_ENTRY_NAME") == ""
369
+
370
+
290
371
  # End
File without changes
File without changes
File without changes
File without changes