Skip to content

Commit c7d5415

Browse files
authored
facts/files: add ls fallback support for files.File fact
1 parent ca78bcd commit c7d5415

25 files changed

+275
-39
lines changed

pyinfra/facts/files.py

Lines changed: 145 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -24,13 +24,21 @@
2424

2525
LINUX_STAT_COMMAND = "stat -c 'user=%U group=%G mode=%A atime=%X mtime=%Y ctime=%Z size=%s %N'"
2626
BSD_STAT_COMMAND = "stat -f 'user=%Su group=%Sg mode=%Sp atime=%a mtime=%m ctime=%c size=%z %N%SY'"
27+
LS_COMMAND = "ls -ld"
2728

2829
STAT_REGEX = (
2930
r"user=(.*) group=(.*) mode=(.*) "
3031
r"atime=([0-9]*) mtime=([0-9]*) ctime=([0-9]*) "
3132
r"size=([0-9]*) (.*)"
3233
)
3334

35+
# ls -ld output: permissions links user group size month day year/time path
36+
# Supports attribute markers: . (SELinux), @ (extended attrs), + (ACL)
37+
# Handles both "MMM DD" and "DD MMM" date formats
38+
LS_REGEX = (
39+
r"^([dlbcsp-][-rwxstST]{9}[.@+]?)\s+\d+\s+(\S+)\s+(\S+)\s+(\d+)\s+(\S+)\s+(\S+)\s+(\S+)\s+(.+)$"
40+
)
41+
3442
FLAG_TO_TYPE = {
3543
"b": "block",
3644
"c": "character",
@@ -82,6 +90,108 @@ def _parse_datetime(value: str) -> Optional[datetime]:
8290
return None
8391

8492

93+
def _parse_ls_timestamp(month: str, day: str, year_or_time: str) -> Optional[datetime]:
94+
"""
95+
Parse ls timestamp format.
96+
Examples: "Jan 1 1970", "Apr 2 2025", "Dec 31 12:34"
97+
"""
98+
try:
99+
# Month abbreviation to number mapping
100+
month_map = {
101+
"Jan": 1,
102+
"Feb": 2,
103+
"Mar": 3,
104+
"Apr": 4,
105+
"May": 5,
106+
"Jun": 6,
107+
"Jul": 7,
108+
"Aug": 8,
109+
"Sep": 9,
110+
"Oct": 10,
111+
"Nov": 11,
112+
"Dec": 12,
113+
}
114+
115+
month_num = month_map.get(month)
116+
if month_num is None:
117+
return None
118+
119+
day_num = int(day)
120+
121+
# Check if year_or_time is a year (4 digits) or time (HH:MM)
122+
if ":" in year_or_time:
123+
# It's a time, assume current year
124+
import time
125+
126+
current_year = time.gmtime().tm_year
127+
hour, minute = map(int, year_or_time.split(":"))
128+
return datetime(current_year, month_num, day_num, hour, minute)
129+
else:
130+
# It's a year
131+
year_num = int(year_or_time)
132+
return datetime(year_num, month_num, day_num)
133+
134+
except (ValueError, TypeError):
135+
return None
136+
137+
138+
def _parse_ls_output(output: str) -> Optional[tuple[FileDict, str]]:
139+
"""
140+
Parse ls -ld output and extract file information.
141+
Example: drwxr-xr-x 1 root root 416 Jan 1 1970 /
142+
"""
143+
match = re.match(LS_REGEX, output.strip())
144+
if not match:
145+
return None
146+
147+
permissions = match.group(1)
148+
user = match.group(2)
149+
group = match.group(3)
150+
size = match.group(4)
151+
date_part1 = match.group(5)
152+
date_part2 = match.group(6)
153+
year_or_time = match.group(7)
154+
path = match.group(8)
155+
156+
# Determine if it's "MMM DD" or "DD MMM" format
157+
if date_part1.isdigit():
158+
# "DD MMM" format (e.g., "22 Jun")
159+
day = date_part1
160+
month = date_part2
161+
else:
162+
# "MMM DD" format (e.g., "Jun 22")
163+
month = date_part1
164+
day = date_part2
165+
166+
# Extract file type from first character of permissions
167+
path_type = FLAG_TO_TYPE[permissions[0]]
168+
169+
# Parse mode (skip first character which is file type, and any trailing attribute markers)
170+
# Remove trailing attribute markers (.@+) if present
171+
mode_str = permissions[1:10] # Take exactly 9 characters after file type
172+
mode = _parse_mode(mode_str)
173+
174+
# Parse timestamp - ls shows modification time
175+
mtime = _parse_ls_timestamp(month, day, year_or_time)
176+
177+
data: FileDict = {
178+
"user": user,
179+
"group": group,
180+
"mode": mode,
181+
"atime": None, # ls doesn't provide atime
182+
"mtime": mtime,
183+
"ctime": None, # ls doesn't provide ctime
184+
"size": try_int(size),
185+
}
186+
187+
# Handle symbolic links
188+
if path_type == "link" and " -> " in path:
189+
filename, target = path.split(" -> ", 1)
190+
data["link_target"] = target.strip("'").lstrip("`")
191+
192+
return data, path_type
193+
194+
85195
class FileDict(TypedDict):
86196
mode: int
87197
size: Union[int, str]
@@ -127,41 +237,54 @@ def command(self, path):
127237
(
128238
# only stat if the path exists (file or symlink)
129239
"! (test -e {0} || test -L {0} ) || "
130-
"( {linux_stat_command} {0} 2> /dev/null || {bsd_stat_command} {0} )"
240+
"( {linux_stat_command} {0} 2> /dev/null || "
241+
"{bsd_stat_command} {0} || {ls_command} {0} )"
131242
),
132243
path,
133244
linux_stat_command=LINUX_STAT_COMMAND,
134245
bsd_stat_command=BSD_STAT_COMMAND,
246+
ls_command=LS_COMMAND,
135247
)
136248

137249
@override
138250
def process(self, output) -> Union[FileDict, Literal[False], None]:
251+
# Try to parse as stat output first
139252
match = re.match(STAT_REGEX, output[0])
140-
if not match:
141-
return None
253+
if match:
254+
mode = match.group(3)
255+
path_type = FLAG_TO_TYPE[mode[0]]
142256

143-
mode = match.group(3)
144-
path_type = FLAG_TO_TYPE[mode[0]]
145-
146-
data: FileDict = {
147-
"user": match.group(1),
148-
"group": match.group(2),
149-
"mode": _parse_mode(mode[1:]),
150-
"atime": _parse_datetime(match.group(4)),
151-
"mtime": _parse_datetime(match.group(5)),
152-
"ctime": _parse_datetime(match.group(6)),
153-
"size": try_int(match.group(7)),
154-
}
257+
data: FileDict = {
258+
"user": match.group(1),
259+
"group": match.group(2),
260+
"mode": _parse_mode(mode[1:]),
261+
"atime": _parse_datetime(match.group(4)),
262+
"mtime": _parse_datetime(match.group(5)),
263+
"ctime": _parse_datetime(match.group(6)),
264+
"size": try_int(match.group(7)),
265+
}
266+
267+
if path_type != self.type:
268+
return False
269+
270+
if path_type == "link":
271+
filename = match.group(8)
272+
filename, target = filename.split(" -> ")
273+
data["link_target"] = target.strip("'").lstrip("`")
155274

156-
if path_type != self.type:
157-
return False
275+
return data
158276

159-
if path_type == "link":
160-
filename = match.group(8)
161-
filename, target = filename.split(" -> ")
162-
data["link_target"] = target.strip("'").lstrip("`")
277+
# Try to parse as ls output
278+
ls_result = _parse_ls_output(output[0])
279+
if ls_result is not None:
280+
data, path_type = ls_result
163281

164-
return data
282+
if path_type != self.type:
283+
return False
284+
285+
return data
286+
287+
return None
165288

166289

167290
class Link(File):

tests/facts/files.Directory/file.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"arg": "/path/to/a/file",
3-
"command": "! (test -e /path/to/a/file || test -L /path/to/a/file ) || ( stat -c 'user=%U group=%G mode=%A atime=%X mtime=%Y ctime=%Z size=%s %N' /path/to/a/file 2> /dev/null || stat -f 'user=%Su group=%Sg mode=%Sp atime=%a mtime=%m ctime=%c size=%z %N%SY' /path/to/a/file )",
3+
"command": "! (test -e /path/to/a/file || test -L /path/to/a/file ) || ( stat -c 'user=%U group=%G mode=%A atime=%X mtime=%Y ctime=%Z size=%s %N' /path/to/a/file 2> /dev/null || stat -f 'user=%Su group=%Sg mode=%Sp atime=%a mtime=%m ctime=%c size=%z %N%SY' /path/to/a/file || ls -ld /path/to/a/file )",
44
"output": [
55
"user=pyinfra group=pyinfra mode=-rwxrwxrwx atime=1594804767 mtime=1594804767 ctime=0 size=8 '/path/to/a/file'"
66
],

tests/facts/files.Directory/link.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"arg": "/home/pyinfra/mylink",
3-
"command": "! (test -e /home/pyinfra/mylink || test -L /home/pyinfra/mylink ) || ( stat -c 'user=%U group=%G mode=%A atime=%X mtime=%Y ctime=%Z size=%s %N' /home/pyinfra/mylink 2> /dev/null || stat -f 'user=%Su group=%Sg mode=%Sp atime=%a mtime=%m ctime=%c size=%z %N%SY' /home/pyinfra/mylink )",
3+
"command": "! (test -e /home/pyinfra/mylink || test -L /home/pyinfra/mylink ) || ( stat -c 'user=%U group=%G mode=%A atime=%X mtime=%Y ctime=%Z size=%s %N' /home/pyinfra/mylink 2> /dev/null || stat -f 'user=%Su group=%Sg mode=%Sp atime=%a mtime=%m ctime=%c size=%z %N%SY' /home/pyinfra/mylink || ls -ld /home/pyinfra/mylink )",
44
"output": [
55
"user=root group=root mode=lrwxrwxrwx atime=1594804774 mtime=1594804770 ctime=0 size=6 '/home/pyinfra/mylink' -> 'file.txt'"
66
],
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
{
2+
"arg": "/",
3+
"command": "! (test -e / || test -L / ) || ( stat -c 'user=%U group=%G mode=%A atime=%X mtime=%Y ctime=%Z size=%s %N' / 2> /dev/null || stat -f 'user=%Su group=%Sg mode=%Sp atime=%a mtime=%m ctime=%c size=%z %N%SY' / || ls -ld / )",
4+
"output": [
5+
"drwxr-xr-x 1 root root 416 Jan 1 1970 /"
6+
],
7+
"fact": {
8+
"group": "root",
9+
"user": "root",
10+
"mode": 755,
11+
"atime": null,
12+
"mtime": "1970-01-01T00:00:00",
13+
"ctime": null,
14+
"size": 416
15+
}
16+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
{
2+
"arg": "/compat/devuan01/var/hostlog",
3+
"command": "! (test -e /compat/devuan01/var/hostlog || test -L /compat/devuan01/var/hostlog ) || ( stat -c 'user=%U group=%G mode=%A atime=%X mtime=%Y ctime=%Z size=%s %N' /compat/devuan01/var/hostlog 2> /dev/null || stat -f 'user=%Su group=%Sg mode=%Sp atime=%a mtime=%m ctime=%c size=%z %N%SY' /compat/devuan01/var/hostlog || ls -ld /compat/devuan01/var/hostlog )",
4+
"output": [
5+
"drwxr-xr-x+ 11 root wheel 420 Mar 28 16:00 /compat/devuan01/var/hostlog"
6+
],
7+
"fact": {
8+
"group": "wheel",
9+
"user": "root",
10+
"mode": 755,
11+
"atime": null,
12+
"mtime": "2025-03-28T16:00:00",
13+
"ctime": null,
14+
"size": 420
15+
}
16+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
{
2+
"arg": ".",
3+
"command": "! (test -e . || test -L . ) || ( stat -c 'user=%U group=%G mode=%A atime=%X mtime=%Y ctime=%Z size=%s %N' . 2> /dev/null || stat -f 'user=%Su group=%Sg mode=%Sp atime=%a mtime=%m ctime=%c size=%z %N%SY' . || ls -ld . )",
4+
"output": [
5+
"drwxr-xr-x@ 12 xonic staff 408 22 Jun 19:00 ."
6+
],
7+
"fact": {
8+
"group": "staff",
9+
"user": "xonic",
10+
"mode": 755,
11+
"atime": null,
12+
"mtime": "2025-06-22T19:00:00",
13+
"ctime": null,
14+
"size": 408
15+
}
16+
}

tests/facts/files.Directory/valid.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"arg": "/home/pyinfra/myd@-_ir",
3-
"command": "! (test -e /home/pyinfra/myd@-_ir || test -L /home/pyinfra/myd@-_ir ) || ( stat -c 'user=%U group=%G mode=%A atime=%X mtime=%Y ctime=%Z size=%s %N' /home/pyinfra/myd@-_ir 2> /dev/null || stat -f 'user=%Su group=%Sg mode=%Sp atime=%a mtime=%m ctime=%c size=%z %N%SY' /home/pyinfra/myd@-_ir )",
3+
"command": "! (test -e /home/pyinfra/myd@-_ir || test -L /home/pyinfra/myd@-_ir ) || ( stat -c 'user=%U group=%G mode=%A atime=%X mtime=%Y ctime=%Z size=%s %N' /home/pyinfra/myd@-_ir 2> /dev/null || stat -f 'user=%Su group=%Sg mode=%Sp atime=%a mtime=%m ctime=%c size=%z %N%SY' /home/pyinfra/myd@-_ir || ls -ld /home/pyinfra/myd@-_ir )",
44
"output": [
55
"user=pyinfra group=pyinfra mode=drw-r--r-- atime=1594804583 mtime=1594804583 ctime=0 size=0 '/home/pyinfra/myd@-_ir'"
66
],

tests/facts/files.File/directory.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"arg": "/home/pyinfra",
3-
"command": "! (test -e /home/pyinfra || test -L /home/pyinfra ) || ( stat -c 'user=%U group=%G mode=%A atime=%X mtime=%Y ctime=%Z size=%s %N' /home/pyinfra 2> /dev/null || stat -f 'user=%Su group=%Sg mode=%Sp atime=%a mtime=%m ctime=%c size=%z %N%SY' /home/pyinfra )",
3+
"command": "! (test -e /home/pyinfra || test -L /home/pyinfra ) || ( stat -c 'user=%U group=%G mode=%A atime=%X mtime=%Y ctime=%Z size=%s %N' /home/pyinfra 2> /dev/null || stat -f 'user=%Su group=%Sg mode=%Sp atime=%a mtime=%m ctime=%c size=%z %N%SY' /home/pyinfra || ls -ld /home/pyinfra )",
44
"output": [
55
"user=root group=root mode=drw-r--r-- atime=1594804583 mtime=1594804583 ctime=0 size=0 '/home/pyinfra'"
66
],

tests/facts/files.File/invalid_output.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"arg": "/home/pyinfra/fil-@_e.txt",
3-
"command": "! (test -e /home/pyinfra/fil-@_e.txt || test -L /home/pyinfra/fil-@_e.txt ) || ( stat -c 'user=%U group=%G mode=%A atime=%X mtime=%Y ctime=%Z size=%s %N' /home/pyinfra/fil-@_e.txt 2> /dev/null || stat -f 'user=%Su group=%Sg mode=%Sp atime=%a mtime=%m ctime=%c size=%z %N%SY' /home/pyinfra/fil-@_e.txt )",
3+
"command": "! (test -e /home/pyinfra/fil-@_e.txt || test -L /home/pyinfra/fil-@_e.txt ) || ( stat -c 'user=%U group=%G mode=%A atime=%X mtime=%Y ctime=%Z size=%s %N' /home/pyinfra/fil-@_e.txt 2> /dev/null || stat -f 'user=%Su group=%Sg mode=%Sp atime=%a mtime=%m ctime=%c size=%z %N%SY' /home/pyinfra/fil-@_e.txt || ls -ld /home/pyinfra/fil-@_e.txt )",
44
"output": [
55
"not-gonna-match"
66
],

tests/facts/files.File/link.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"arg": "/home/pyinfra/mylink",
3-
"command": "! (test -e /home/pyinfra/mylink || test -L /home/pyinfra/mylink ) || ( stat -c 'user=%U group=%G mode=%A atime=%X mtime=%Y ctime=%Z size=%s %N' /home/pyinfra/mylink 2> /dev/null || stat -f 'user=%Su group=%Sg mode=%Sp atime=%a mtime=%m ctime=%c size=%z %N%SY' /home/pyinfra/mylink )",
3+
"command": "! (test -e /home/pyinfra/mylink || test -L /home/pyinfra/mylink ) || ( stat -c 'user=%U group=%G mode=%A atime=%X mtime=%Y ctime=%Z size=%s %N' /home/pyinfra/mylink 2> /dev/null || stat -f 'user=%Su group=%Sg mode=%Sp atime=%a mtime=%m ctime=%c size=%z %N%SY' /home/pyinfra/mylink || ls -ld /home/pyinfra/mylink )",
44
"output": [
55
"user=root group=root mode=lrwxrwxrwx atime=1594804774 mtime=1594804770 ctime=0 size=6 '/home/pyinfra/mylink' -> 'file.txt'"
66
],

0 commit comments

Comments
 (0)