Skip to content

Commit a6aa785

Browse files
committed
✨ Added FilePermissions dataclass
xtl.common.os:FileType - Enum for POSIX file types xtl.common.os:FilePermissionBit - Dataclass for storing the POSIX permissions of a single group, e.g. owner permissions - Can be initialized by an integer or a string (e.g. 'rwx') - The permissions can be returned as string, octal, decimal or tuple when using the .string, .octal, .decimal or .tuple properties - The read, write, execute bits can be toggled by changing the .can_read, .can_write and .can_execute properties xtl.common.os:FilePermissions - Dataclass for storing the POSIX permissions for owner, group and other, but also, optionally, the file type - Can be initialized by passing one or three string or integer representation(s) of the permissions - The .from_path method constructs a FilePermissions instance from the actual permissions of the provided path - The permissions can be returned as a string, octal, decimal or tuple when using the .string, .octal, .decimal or .tuple properties tests.common.test_os - Tests for FilePermissionsBit and FilePermissions
1 parent e98a737 commit a6aa785

File tree

3 files changed

+429
-9
lines changed

3 files changed

+429
-9
lines changed

src/xtl/common/os.py

Lines changed: 371 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,15 @@
1+
from dataclasses import dataclass, field
12
import os
23
from pathlib import Path
34
import platform
5+
from types import NoneType
46
from typing import Optional
7+
import sys
8+
if sys.version_info < (3, 11):
9+
from enum import Enum
10+
class StrEnum(str, Enum): ...
11+
else:
12+
from enum import StrEnum
513

614

715
def get_os_name_and_version() -> str:
@@ -83,3 +91,366 @@ def chmod_recursively(path: str | Path, files_permissions: Optional[int | str] =
8391
path.chmod(mode=files_permissions) if files_permissions else None
8492
return
8593
path.chmod(mode=directories_permissions) if directories_permissions else None
94+
95+
96+
class FileType(StrEnum):
97+
"""
98+
POSIX file type representation.
99+
"""
100+
FILE = '-'
101+
DIRECTORY = 'd'
102+
SYMLINK = 'l'
103+
CHARACTER_DEVICE = 'c'
104+
BLOCK_DEVICE = 'b'
105+
SOCKET = 's'
106+
FIFO = 'p'
107+
108+
_file_type_mappings: dict[str, FileType] = {
109+
'-': FileType.FILE,
110+
'd': FileType.DIRECTORY,
111+
'l': FileType.SYMLINK,
112+
'c': FileType.CHARACTER_DEVICE,
113+
'b': FileType.BLOCK_DEVICE,
114+
's': FileType.SOCKET,
115+
'p': FileType.FIFO
116+
}
117+
"""Mappings of POSIX file types to the respective `FileType` enum"""
118+
119+
_permission_mappings: dict[int, str] = {
120+
0o0: '---', 0o1: '--x', 0o2: '-w-', 0o3: '-wx',
121+
0o4: 'r--', 0o5: 'r-x', 0o6: 'rw-', 0o7: 'rwx'
122+
}
123+
""""Mappings of POSIX permissions from octal to string representation"""
124+
125+
_permission_mappings_short: dict[int, str] = {
126+
0o0: '-', 0o1: 'x', 0o2: 'w', 0o3: 'wx',
127+
0o4: 'r', 0o5: 'rx', 0o6: 'rw', 0o7: 'rwx'
128+
}
129+
"""Mappings of POSIX permissions from octal to shorthand string representation"""
130+
131+
_valid_permission_strings: dict[str | None, int] = {
132+
'': 0o0, '-': 0o0, '---': 0o0, None: 0o0,
133+
'x': 0o1, '--x': 0o1,
134+
'w': 0o2, '-w-': 0o2,
135+
'wx': 0o3, '-wx': 0o3,
136+
'r': 0o4, 'r--': 0o4,
137+
'rx': 0o5, 'r-x': 0o5,
138+
'rw': 0o6, 'rw-': 0o6,
139+
'rwx': 0o7
140+
}
141+
"""Mapping of valid POSIX permission strings to their octal representation"""
142+
143+
144+
@dataclass(eq=True, order=True)
145+
class FilePermissionsBit:
146+
"""
147+
Representation of POSIX file permissions for a single group, e.g., permissions for
148+
owner.
149+
"""
150+
151+
value: int | str | None
152+
"""Permission value in octal format"""
153+
154+
def __post_init__(self):
155+
self._parse_value()
156+
157+
def _parse_value(self):
158+
"""
159+
Parse the value to ensure it is a valid octal representation.
160+
"""
161+
value = self.value
162+
if isinstance(value, int):
163+
if value < 0o0:
164+
raise ValueError(f'\'value\' must be a non-negative integer')
165+
if value > 0o7:
166+
raise ValueError(f'\'value\' must be a less than 8')
167+
elif isinstance(value, str) or value is None:
168+
if value in _valid_permission_strings:
169+
self.value = _valid_permission_strings[value]
170+
elif value.startswith('0o'):
171+
self.value = int(value, 8)
172+
self._parse_value()
173+
else:
174+
raise ValueError(f'\'value\' must be a valid permission string, '
175+
f'not {value!r}')
176+
else:
177+
raise TypeError(f'\'value\' must be an int or str, not {type(value)}')
178+
179+
@property
180+
def string(self) -> str:
181+
"""
182+
Get the short string representation of the permission bit, e.g., `'rw'`.
183+
184+
:return: The shorthand string representation of the permission bit.
185+
"""
186+
return _permission_mappings_short[self.value]
187+
188+
@property
189+
def string_canonical(self) -> str:
190+
"""
191+
Get the canonical string representation of the permission bit, e.g., `'rw-'`.
192+
193+
:return: The canonical string representation of the permission bit.
194+
"""
195+
return _permission_mappings[self.value]
196+
197+
@property
198+
def octal(self) -> str:
199+
"""
200+
Get the octal representation of the permission bit, e.g., `'0o6'`.
201+
202+
:return: The octal representation of the permission bit.
203+
"""
204+
return oct(self.value)
205+
206+
@property
207+
def decimal(self) -> int:
208+
"""
209+
Get the decimal representation of the permission bit, e.g., `6`.
210+
211+
:return: The decimal representation of the permission bit.
212+
"""
213+
return self.value
214+
215+
@property
216+
def can_read(self) -> bool:
217+
"""
218+
Check if the permission bit allows reading.
219+
220+
:return: True if it has read permission, False otherwise.
221+
"""
222+
return bool(self.value & 0o4)
223+
224+
@can_read.setter
225+
def can_read(self, value: bool):
226+
"""
227+
Set the permission bit to allow or disallow reading.
228+
"""
229+
if not isinstance(value, bool):
230+
raise TypeError(f'\'value\' must be a bool, not {type(value)}')
231+
if value != self.can_read:
232+
self.value ^= 0o4 # Toggle the read permission bit
233+
234+
@property
235+
def can_write(self) -> bool:
236+
"""
237+
Check if the permission bit allows writing.
238+
239+
:return: True if it has write permission, False otherwise.
240+
"""
241+
return bool(self.value & 0o2)
242+
243+
@can_write.setter
244+
def can_write(self, value: bool):
245+
"""
246+
Set the permission bit to allow or disallow writing.
247+
"""
248+
if not isinstance(value, bool):
249+
raise TypeError(f'\'value\' must be a bool, not {type(value)}')
250+
if value != self.can_write:
251+
self.value ^= 0o2 # Toggle the write permission bit
252+
253+
@property
254+
def can_execute(self) -> bool:
255+
"""
256+
Check if the permission bit allows executing.
257+
258+
:return: True if it has execute permission, False otherwise.
259+
"""
260+
return bool(self.value & 0o1)
261+
262+
@can_execute.setter
263+
def can_execute(self, value: bool):
264+
"""
265+
Set the permission bit to allow or disallow executing.
266+
"""
267+
if not isinstance(value, bool):
268+
raise TypeError(f'\'value\' must be a bool, not {type(value)}')
269+
if value != self.can_execute:
270+
self.value ^= 0o1 # Toggle the execute permission bit
271+
272+
@property
273+
def tuple(self) -> tuple[bool, bool, bool]:
274+
"""
275+
Get the permission bit as a tuple of booleans (read, write, execute).
276+
277+
:return: A tuple containing three boolean values representing read, write, and
278+
execute permissions.
279+
"""
280+
return self.can_read, self.can_write, self.can_execute
281+
282+
def __str__(self) -> str:
283+
return self.string
284+
285+
def __repr__(self) -> str:
286+
return f'{self.__class__.__name__}(value={self.string_canonical!r})'
287+
288+
289+
@dataclass(eq=True, order=True)
290+
class FilePermissions:
291+
"""
292+
Representation of POSIX file permissions for owner, group, and other.
293+
"""
294+
295+
owner: int | str | FilePermissionsBit = field(
296+
default_factory=lambda: FilePermissionsBit(0o0))
297+
group: int | str | FilePermissionsBit = field(
298+
default_factory=lambda: FilePermissionsBit(0o0))
299+
other: int | str | FilePermissionsBit = field(
300+
default_factory=lambda: FilePermissionsBit(0o0))
301+
file_type: Optional[str | FileType] = field(default=None, compare=False)
302+
303+
def __post_init__(self):
304+
# Check if a single value is provided that contains all permissions
305+
value = getattr(self, 'owner')
306+
if isinstance(value, int):
307+
if value <= 0o7:
308+
# Single digit octal
309+
pass
310+
elif 0o7 < value < 0o777:
311+
self.owner, self.group, self.other = self._split_octal(value)
312+
else:
313+
raise ValueError(f'Permissions must be a 3-digit octal, not {value:#o}')
314+
elif isinstance(value, str):
315+
if (len(value) == 3 and value.isnumeric()) or \
316+
(len(value) == 5 and value.startswith('0o')):
317+
# e.g. '760' or '0o760'
318+
self.owner, self.group, self.other = self._split_octal(int(value, 8))
319+
elif len(value) == 9:
320+
# e.g. 'rwxrw----'
321+
self.owner = value[0:3]
322+
self.group = value[3:6]
323+
self.other = value[6:9]
324+
elif len(value) == 10:
325+
# e.g. 'drwxrw----' (includes file type)
326+
self.file_type = value[0]
327+
self.owner = value[1:4]
328+
self.group = value[4:7]
329+
self.other = value[7:10]
330+
331+
# Validate and cast the values
332+
for name in ['owner', 'group', 'other']:
333+
value = getattr(self, name)
334+
if isinstance(value, (int, str, NoneType)):
335+
try:
336+
value = FilePermissionsBit(value)
337+
setattr(self, name, value)
338+
except ValueError as e:
339+
raise ValueError(f'\'{name}\' must be a valid permission string, '
340+
f'not {value!r}') from e
341+
elif not isinstance(value, FilePermissionsBit):
342+
raise TypeError(f'\'{name}\' must be an int, str or '
343+
f'{FilePermissionsBit.__class__.__name__}, not '
344+
f'{type(value)}')
345+
if self.file_type is not None:
346+
if isinstance(self.file_type, str) and self.file_type in _file_type_mappings:
347+
self.file_type = _file_type_mappings[self.file_type]
348+
elif not isinstance(self.file_type, FileType):
349+
raise TypeError(f'\'file_type\' must be a str or '
350+
f'{FileType.__class__.__name__}, not '
351+
f'{type(self.file_type)}')
352+
353+
@staticmethod
354+
def _split_octal(value: int) -> tuple[int, int, int]:
355+
"""
356+
Split a 3-digit octal value into owner, group, and other permission digits.
357+
358+
:param value: The octal value in integer representation (e.g., `0o760` or `496`
359+
for
360+
:return: A tuple containing the owner, group, and other permission digits.
361+
"""
362+
owner = (value >> 6) & 0o7
363+
group = (value >> 3) & 0o7
364+
other = value & 0o7
365+
return owner, group, other
366+
367+
@property
368+
def octal(self) -> str:
369+
"""
370+
Get the octal representation of the file permissions, e.g., `'0o644'`.
371+
372+
:return: The octal representation of the file permissions.
373+
"""
374+
return f'0o{self.owner.octal[2:]}{self.group.octal[2:]}{self.other.octal[2:]}'
375+
376+
@property
377+
def decimal(self) -> int:
378+
"""
379+
Get the decimal representation of the file permissions, e.g., `420` for `0o640`.
380+
381+
:return: The decimal representation of the file permissions.
382+
"""
383+
return int(self.octal, 8)
384+
385+
@property
386+
def string(self) -> str:
387+
"""
388+
Get the string representation of the file permissions, e.g., `'rwxr-xr--'`. If
389+
`file_type` is set, then it is included in the string representation (length 10),
390+
otherwise it is omitted (length 9).
391+
392+
:return: The string representation of the file permissions.
393+
"""
394+
s = (f'{self.owner.string_canonical}{self.group.string_canonical}'
395+
f'{self.other.string_canonical}')
396+
if self.file_type is not None:
397+
s = f'{self.file_type.value}{s}'
398+
return s
399+
400+
@property
401+
def tuple(self) -> tuple[tuple[bool, bool, bool], tuple[bool, bool, bool],
402+
tuple[bool, bool, bool]]:
403+
"""
404+
Get the permissions as three (read, write, execute) tuples for owner, group and
405+
other.
406+
407+
:return: A tuple containing three tuples, each representing the permissions for
408+
owner, group, and other.
409+
"""
410+
return self.owner.tuple, self.group.tuple, self.other.tuple
411+
412+
def __str__(self) -> str:
413+
return self.string
414+
415+
def __repr__(self) -> str:
416+
return f'{self.__class__.__name__}(value={self.string!r})'
417+
418+
@classmethod
419+
def from_path(cls, p: str | Path) -> 'FilePermissions':
420+
"""
421+
Create a `FilePermissions` object from a file or directory path.
422+
423+
:param p: The path to the file or directory.
424+
:return: A `FilePermissions` object.
425+
"""
426+
p = Path(p)
427+
if not p.exists():
428+
raise FileNotFoundError(f'File does not exist: {p}')
429+
430+
file_type = None
431+
if p.is_file():
432+
file_type = FileType.FILE
433+
elif p.is_dir():
434+
file_type = FileType.DIRECTORY
435+
elif p.is_symlink():
436+
file_type = FileType.SYMLINK
437+
elif p.is_char_device():
438+
file_type = FileType.CHARACTER_DEVICE
439+
elif p.is_block_device():
440+
file_type = FileType.BLOCK_DEVICE
441+
elif p.is_socket():
442+
file_type = FileType.SOCKET
443+
elif p.is_fifo():
444+
file_type = FileType.FIFO
445+
446+
permissions = p.stat().st_mode & 0o777 # integer representation
447+
permissions = oct(permissions)[2:] # octal string
448+
owner = int(f'0o{permissions[0]}', 8)
449+
group = int(f'0o{permissions[1]}', 8)
450+
other = int(f'0o{permissions[2]}', 8)
451+
return cls(
452+
owner=_permission_mappings[owner],
453+
group=_permission_mappings[group],
454+
other=_permission_mappings[other],
455+
file_type=file_type
456+
)

0 commit comments

Comments
 (0)