FileHiker 0.3.9.2__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.
Files changed (30) hide show
  1. filehiker/__init__.py +4 -0
  2. filehiker/__main__.py +249 -0
  3. filehiker/cases/20260806o1211.sorted.txt +49 -0
  4. filehiker/cases/20260806o1213.invert.py +30 -0
  5. filehiker/cases/20260806o1215.inverted.txt +49 -0
  6. filehiker/cases/README.md +24 -0
  7. filehiker/cases/^^file-to-ignore^^.txt +3 -0
  8. filehiker/cases/^^folder-to-ignore^^/more123.txt +3 -0
  9. filehiker/cases/dir_2/file_01_abc.txt +1 -0
  10. filehiker/cases/dir_2b.txt +1 -0
  11. filehiker/cases/dir_3/dir_31/dir_311/^^dir_3111^^/file_03_more.txt +3 -0
  12. filehiker/cases/dir_3/dir_31/dir_311/dir_3113/file_04_bcd.txt +1 -0
  13. filehiker/cases/dir_3/dir_31/file_02_abc.txt +1 -0
  14. filehiker/cases/dir_4.txt +1 -0
  15. filehiker/cases/dir_5/dir_51/dir_511/dir_5111/file_08_k.txt +1 -0
  16. filehiker/cases/dir_5/dir_51/dir_511/dir_5111/file_10_m.txt +1 -0
  17. filehiker/cases/dir_5/dir_51/dir_511/dir_5111/file_11_n.txt +1 -0
  18. filehiker/cases/dir_5/dir_52/dir_521/dir_5212/file_08_g.txt +1 -0
  19. filehiker/cases/dir_5/dir_52/file_07_c.txt +1 -0
  20. filehiker/cases/dir_5/dir_53/dir_531/dir_5311/dir_53111/dir_531111/file_12_x.txt +1 -0
  21. filehiker/cases/dir_5/dir_53/dir_531/dir_5311/dir_53111/dir_531111/file_13_y.txt +1 -0
  22. filehiker/cases/dir_5/dir_53/dir_531/dir_5311/dir_53111/dir_531111/file_14_z.txt +1 -0
  23. filehiker/cases/dir_5/file_05_a.txt +1 -0
  24. filehiker/cases/dir_5/file_06_h.txt +1 -0
  25. filehiker/cases/dir_5/file_07_p.txt +1 -0
  26. filehiker/filehiker.py +833 -0
  27. filehiker-0.3.9.2.dist-info/METADATA +226 -0
  28. filehiker-0.3.9.2.dist-info/RECORD +30 -0
  29. filehiker-0.3.9.2.dist-info/WHEEL +5 -0
  30. filehiker-0.3.9.2.dist-info/top_level.txt +1 -0
filehiker/filehiker.py ADDED
@@ -0,0 +1,833 @@
1
+ #! /usr/bin/env python
2
+ # file : 20250406°0911 filehiker.py
3
+ # summary : This finds a filesystem item's neighbour relative to a given path. Find
4
+ # either the next or the previous neighbour, depending on the direction flag.
5
+ # license : BSD 3-Clause | © 2025 – 2026 Norbert C. Maier
6
+ # status : Applicable
7
+
8
+
9
+ import bisect
10
+ import fnmatch
11
+ import itertools
12
+ import os, sys
13
+ import pathlib # Path
14
+ import typing # Optional, Union
15
+
16
+
17
+ ## \register class 20260810°0911 FileHiker <del>20090725°0144 IrunFind</del>
18
+ # @brief Class to find the neighbour of a given target inside a given base path
19
+ # @details If the given path does not exist, then find the nearest existing target according the sort order
20
+ # \remark Code implented after file 20260808°0811 probe_327_walk.py
21
+ # \remark Delete <del>Rename</del> class IrunFind to FileHiker [chg 20260810°0811]
22
+ class FileHiker :
23
+
24
+ ## \register Method 20260810°0915 <del>20090725°0147 constructor</del>
25
+ # \brief …
26
+ # \param verbose — Flag telling verbose mode on or off
27
+ def __init__ ( self
28
+ , basepath = ''
29
+ , direction = True
30
+ , ignorewcs = None
31
+ , verbose = False
32
+ , selftest = False # Only use from __main__.py! Ugly flag, but easiest solution, see issue 20260811°1021 'Command `python.exe -m filehiker`'
33
+ ) :
34
+
35
+ # (1) Complete properties [seq 20260811°0911]
36
+ # This are instance level properties, they will override the class level attributed possibly defined on top of the class
37
+
38
+ ## \register prop 20260811°0913
39
+ ## \brief Direction flag
40
+ self.__bDirection = direction
41
+
42
+ ## \register prop 20260811°0915
43
+ ## \brief Direction flag
44
+ self.__bVerbose = verbose
45
+
46
+ ## \register prop 20250729°0911
47
+ # \brief The default ignore list
48
+ self.__lstIgnorePats = ignorewcs \
49
+ if ignorewcs != None \
50
+ else [ '.git'
51
+ , '.hg'
52
+ , '.svn'
53
+ , '.vs' # Visual Studio
54
+ , '.vscode'
55
+ , '__*__' # E.g. '__pycache__'
56
+ , '__*__.*' # This will unfortunately also hit '__init__.py' and '__main__.py' (see todo 20260811°0831 'Finetune ignore feature')
57
+ , '^^*^^' # Specific for self test
58
+ , '^^*^^.*' # Specific for self test
59
+ , 'Debug*' # Formerly 'Debug' plus 'Debug_TMP' and possibly 'ifonor'
60
+ , 'obj'
61
+ , 'tags'
62
+ , 'temp'
63
+ , 'tmp'
64
+ ]
65
+
66
+ ## \register prop 20260811°0917
67
+ # \brief The base path
68
+ self.__sBasePath = basepath
69
+
70
+ # (2) Notification [seq 20260811°0919]
71
+ if self.__bVerbose:
72
+ sMsg = '[Dbg_4111] *** Here comes a new FileHiker instance …'
73
+ print(sMsg)
74
+
75
+ # (3) Retrieve the decision aids [seq 20260811°0921]
76
+ # (3.1) Is given basepath a relative one? [seq 20260811°0923] When and why shall this happen?!
77
+ bRelativeBasePath = False if os.path.isabs(self.__sBasePath) else True
78
+
79
+ # (3.2) Is this a selftest run? [seq 20260811°0925]
80
+ bIsSelftest = selftest # Testing for `os.path.basename(__file__) == '__main.py__'` won´t work here!
81
+
82
+ # (3.3) Provide the involved folders [seq 20260811°0927]
83
+ sCwd = FileHiker.normaleis(os.getcwd())
84
+ sModLoc = FileHiker.normaleis(os.path.abspath(os.path.dirname(__file__))) # Full path of current module — Interposing abspath() is defensive
85
+
86
+ # (4.1) Notification [seq 20260811°0933]
87
+ sMsg = f'[Dbg_4113] Basepath is empty or relative' \
88
+ + f'\n • Selftest flag = {bIsSelftest}' \
89
+ + f'\n • Relative flag = {bRelativeBasePath}' \
90
+ + f'\n • FileHiker home = {sModLoc}' \
91
+ + f'\n • CWD = {sCwd}' \
92
+ + f'\n • BasePath = {self.__sBasePath}' \
93
+ + f'\n • Direction = {'Forward' if self.__bDirection else 'Backward'}' \
94
+ + f'\n • Ignore list = {self.__lstIgnorePats}'
95
+ print(sMsg)
96
+
97
+ # note 20260811°1011 'About BasePath determination' — Draft, possibly not really followed!
98
+ # Selftest with parameter 'selftest' is just a proposal, not (yet?) implemented
99
+ # +——————————————————————+————————————————————+———————————————————————+————————————————————+
100
+ # | | Empty basepath | Relative basepath | Absolute basepath |
101
+ # +——————————————————————+————————————————————+———————————————————————+————————————————————+
102
+ # | Regular call by some | CWD | CWD plus | Absolute path |
103
+ # | other script | | relative path | as is |
104
+ # +——————————————————————+————————————————————+———————————————————————+————————————————————+
105
+ # | Selftest | FileHiker folder | FileHiker folder | Absolute path |
106
+ # | | join 'cases' | join relative path | as is |
107
+ # +——————————————————————+————————————————————+———————————————————————+————————————————————+
108
+ # | # Selftest with | FileHiker folder | FileHiker folder | Absolute path |
109
+ # | # param 'selftest' | join CWD | join relative path | as is |
110
+ # +——————————————————————+————————————————————+———————————————————————+————————————————————+
111
+
112
+ # (4) Determine base path [seq 20260811°0931]
113
+ # The determination shall go by the following rules:
114
+ # 1. If given basepath is relative, treat it like an empty basepath with the relative part attached
115
+ # 2. If given basepath is empty, make it the folder where from Python was called, means the CWD
116
+ # 3. If it is a selftest, make it the folder where the package resides (which is easy
117
+ # to know in a development installation, harder to know in a PyPi installation)
118
+ if self.__sBasePath == '' or bRelativeBasePath:
119
+ pass
120
+
121
+ # (5) Implement just some simple first logic [seq 20260811°0941]
122
+ if self.__sBasePath == '':
123
+
124
+ if bIsSelftest:
125
+ sPth = os.path.join(sModLoc, 'cases')
126
+ sPth = FileHiker.normaleis(sPth)
127
+ self.__sBasePath = sPth
128
+ else:
129
+ self.__sBasePath = sCwd
130
+
131
+ FileHiker.normaleis(self.__sBasePath)
132
+ sMsg = f'[Dbg_4115] Set BasePath = {self.__sBasePath}'
133
+ print(sMsg)
134
+
135
+
136
+ return
137
+
138
+
139
+ ## \entry Private method 20260806°1511 find_left_neighbour
140
+ # \brief Backward walking work horse …
141
+ # \param self — Mandatory object
142
+ # \param sTgt_FolderName — The target folder
143
+ # \param sTgt_ElementName — The target filename, if target is a folder, this is blank
144
+ # \return The wanted left neighbour
145
+ def __find_left_neighbour ( self
146
+ , sTgt_FolderName #
147
+ , sTgt_ElementName #
148
+ ) :
149
+
150
+ """
151
+ Returns the left neighbour (previous item in global alphabetical order)
152
+ of the target file/directory, searching upward through the file system,
153
+ but not above base_path. If no left neighbour is found, wraps around to
154
+ the deepest last item of base_path.
155
+ """
156
+
157
+ # (L.4) Search the left neighbour [seq 20260806°1521]
158
+ while True :
159
+
160
+ # (L.4.1) Pessimistic predetermination [line 20260806°1522]
161
+ sNeighbour = None
162
+
163
+ # (L.4.2) Retrieve children of currently inspected folder [condi 20260806°1523]
164
+ try :
165
+ children = self.__get_listing(sTgt_FolderName) # [line 20260808°1111]
166
+ except FileNotFoundError :
167
+ sMsg = f'[Err_4117] Fatal — Directory not found: "{sTgt_FolderName}"'
168
+ raise FileNotFoundError(sMsg)
169
+
170
+ # (L.4.3) … [condi 20260806°1525]
171
+ if sTgt_ElementName in children :
172
+
173
+ # (L.4.3.1) Target is in current folder [seq 20260806°1527]
174
+ # (L.4.3.1.1) … [seq 20260806°1531]
175
+ target_index = children.index(sTgt_ElementName)
176
+
177
+ # (L.4.3.1.2) Is the wanted neighbour in this directory? [condi 20260806°1533]
178
+ if target_index > 0 :
179
+
180
+ # (L.4.3.1.2.1) Yes, left sibling is present [seq 20260806°1534]
181
+ # (L.4.3.1.2.1.1) Retrieve the neighbour [seq 20260806°1535]
182
+ left_sibling = children[target_index - 1]
183
+ left_sibling_path = os.path.join(sTgt_FolderName, left_sibling)
184
+
185
+ # (L.4.3.1.2.1.2) Is it a file or a folder? [condi 20260806°1537]
186
+ if os.path.isdir(left_sibling_path) :
187
+ sNeighbour = self.__get_deepest_last_item(left_sibling_path) # 🚀 Found
188
+ ###sNeighbour = left_sibling_path # 🚀 Found — Deliver directory itself, not deepest child [chg 20260818°0821´01]
189
+ else :
190
+ sNeighbour = left_sibling_path # 🚀 Found
191
+
192
+ else :
193
+
194
+ # (L.4.3.1.2.2) Neighbour is not in this directory [seq 20260806°1540]
195
+ # Note : We don´t even need ask `if sTgt_FolderName == base_path`, the both cases are satisfied here
196
+ # Asking `if sTgt_FolderName == base_path` were only useful if we want skip outputting the base path
197
+ # and immediately wrap with get_deepest_last_item(base_path), as was the case in the initial versions
198
+ # Remember : todo 20260808°0921 'Clean up the slash mess'
199
+ sNeighbour = sTgt_FolderName # 🚀 Found — [line 20260806°1621] [chg 20260808°0913] Heureka! Just deliver the base folder itself.
200
+
201
+ else :
202
+
203
+ # (L.4.3.2) Target is not found in current folder, so move up [seq 20260806°1551]
204
+ # (L.4.3.2.1) Are we in the base folder? [condi 20260806°1552]
205
+ if sTgt_FolderName == self.__sBasePath :
206
+ # (L.4.3.2.1.1) Yes, we are in base folder, thus wrap around — Find deepest last item of base_path [seq 20260806°1553]
207
+ sNeighbour = self.__get_deepest_last_item(self.__sBasePath) # 🚀 Found
208
+ else :
209
+ # (L.4.3.2.1.2) No, we are not in base folder — Continue searching [seq 20260806°1555]
210
+ sTgt_ElementName = os.path.basename(sTgt_FolderName)
211
+ sTgt_FolderName = os.path.dirname(sTgt_FolderName)
212
+
213
+ # (L.4.4) Finished? Deliver result or continue searching [condi 20260806°1557]
214
+ if sNeighbour :
215
+ return sNeighbour
216
+
217
+
218
+ ## \entry Private method 20260806°1811 find_right_neighbour
219
+ # \brief Forward walking work horse — Shall be symmetric to find_left_neighbour but is not
220
+ # — Discontinued in favour of method 20260818°0911 __find_right_neighbouur_4
221
+ # \param self — The mandatory object
222
+ # \param sTgt_FolderName — The root of the search area
223
+ # \param sTgt_ElementName — The item inside root for which the right neighbour is wanted
224
+ # \return The wanted right neighbour
225
+ # \note See issue 20260818°0811 'Process order forward not as wanted'
226
+ def __find_right_neighbour(self, sTgt_FolderName, sTgt_ElementName) : # [chg 20260818°0831´05] Formerly `__find_right_neighbour(self, s_target_path)`
227
+
228
+ """
229
+ Returns the right neighbour (next item in global alphabetical order)
230
+ of the target file/directory, searching upward through the file system,
231
+ but not above base_path. If no right neighbour is found, wraps around to
232
+ the deepest first item of base_path.
233
+ """
234
+
235
+ # (R.4) Search the right neighbour [seq 20260806°1821]
236
+ while True :
237
+
238
+ # (R.4.1) Pessimistic predetermination [line 20260806°1822]
239
+ sNeighbour = None
240
+
241
+ # (R.4.2) Retrieve children of the currently inspected folder [condi 20260806°1823]
242
+ try :
243
+ children = self.__get_listing(sTgt_FolderName) # [line 20260808°1113]
244
+ except FileNotFoundError :
245
+ sMsg = f'[Err_4119] Fatal — Directory not found: "{sTgt_FolderName}"'
246
+ raise FileNotFoundError(sMsg)
247
+
248
+ # (R.4.3) … [condi 20260806°1825]
249
+ if sTgt_ElementName in children :
250
+
251
+ # (R.4.3.1) Target is in current folder [seq 20260806°1827]
252
+ # (R.4.3.1.1) … [seq 20260806°1831]
253
+ target_index = children.index(sTgt_ElementName)
254
+
255
+ # (R.4.3.1.2) Is the wanted neighbour in this directory? [condi 20260806°1833]
256
+ if target_index < len(children) - 1 :
257
+
258
+ # (R.4.3.1.2.1) Yes, right sibling present [seq 20260806°1834]
259
+ # (R.4.3.1.2.1.1) Retrieve the neighbour [seq 20260806°1835]
260
+ right_sibling = children[target_index + 1]
261
+ right_sibling_path = os.path.join(sTgt_FolderName, right_sibling)
262
+
263
+ # (R.4.3.1.2.1.2) Is it a file or a folder? [condi 20260806°1837]
264
+ if os.path.isdir(right_sibling_path) :
265
+ sNeighbour = self.__get_first_folder_item(right_sibling_path) # 🚀 Found
266
+ ###sNeighbour = right_sibling_path # 🚀 Found — Deliver directory itself, not deepest child [chg 20260818°0821´02]
267
+ else :
268
+ sNeighbour = right_sibling_path # 🚀 Found
269
+
270
+ else :
271
+
272
+ # (R.4.3.1.2.2) Neighbour is not in this directory [seq 20260806°1840]
273
+ # Note : We don´t even need ask `if sTgt_FolderName == base_path`, the both cases are satisfied here
274
+ # Asking `if sTgt_FolderName == base_path` were only useful if we want skip outputting the base path
275
+ # and immediately wrap with get_deepest_last_item(base_path), as was the case in the initial versions
276
+ # Remember : todo 20260808°0921 'Clean up the slash mess'
277
+ sNeighbour = sTgt_FolderName # 🚀 Found — Empiric experiment [chg 20260808°0911] Heureka! Also deliver the base folder itself
278
+
279
+ else:
280
+
281
+ # (R.4.3.2) Target is not found in current folder, so move up [seq 20260806°1851]
282
+ # (R.4.3.2.1) Are we in the base folder? [condi 20260806°1552]
283
+ if sTgt_FolderName == self.__sBasePath :
284
+ # (R.4.3.2.1.1) Yes, we are in base folder, thus wrap around — Find deepest first item of base_path [seq 20260806°1853]
285
+ sNeighbour = self.__get_first_folder_item(self.__sBasePath) # 🚀 Found
286
+ else :
287
+ # (R.4.3.2.1.2) No, we are not in base folder — Continue searching [seq 20260806°1855]
288
+ sTgt_ElementName = os.path.basename(sTgt_FolderName)
289
+ sTgt_FolderName = os.path.dirname(sTgt_FolderName)
290
+
291
+ # (R.4.4) Finished? Deliver result or continue searching [condi 20260806°1857]
292
+ if sNeighbour :
293
+ return sNeighbour
294
+
295
+
296
+ ## \entry Private method 20260818°0911 __find_right_neighbouur_4 (replacing 20260806°1811)
297
+ # \brief Forward walking work horse — Outputs symmetric to find_left_neighbour
298
+ # \details This is horrible code, just it seems to work as wanted. Has to be heavily refactored
299
+ # \param self — The mandarory objedt
300
+ # \param given_path — The target for which the right neighbour is wanted
301
+ # \param recursive — Flag telling whether it is the original call or a recursive one
302
+ # \return The wanted right neighbour
303
+ # \note See issue 20260818°0811 'Process order forward not as wanted'
304
+ # \note See issue 20260818°1131 'Refactor __find_right_neighbouur_4'
305
+ def __find_right_neighbouur_4 ( self
306
+ , given_path # Must be normalized
307
+ , recursive = False
308
+ ) :
309
+
310
+ # () Set pessibmistic default [line 20260818°0913]
311
+ neighbour = None
312
+
313
+ # () Breakpoint [line 20260818°0914]
314
+ if given_path.endswith('README.md') :
315
+ pass
316
+
317
+ # () Extract the directory and the base name of the given path [line 20260818°0915]
318
+ dir_name, base_name = os.path.split(given_path)
319
+
320
+ # (.) Is given_path a directory? [condi 20260818°0917]
321
+ if os.path.isdir(given_path) :
322
+
323
+ # (.1) Yes — Given path is a directory [seq 20260818°0919]
324
+ # (.1.1) Retrieve children of the given path [seq 20260818°0921]
325
+ items = self.__get_listing(given_path) # Alphabetically sorted list, cleaned from ignored items
326
+
327
+ # (.1.2) Has the given folder children? [condi 20260818°0923]
328
+ if len(items) > 0 :
329
+ # () Yes — Given folder has at least one child [seq 20260818°0925]
330
+ neighbour = os.path.join(given_path, items[0])
331
+ return neighbour # 🚀
332
+ else :
333
+
334
+ # () No — The given folder has no children [seq 20260818°0927]
335
+ # (.1) … [seq 20260818°0929]
336
+ if recursive : # Just a try — Happened to work
337
+ return given_path # 🚀
338
+
339
+ # (.2) Peek into it to see whether it has children, if no children then get next peer [seq 20260818°0931]
340
+ dirnam, basenam = os.path.split(dir_name)
341
+ childs = self.__get_listing(dirnam)
342
+ ndx = childs.index(basenam)
343
+
344
+ # (.3) … [condi 20260818°0933]
345
+ if len(childs) < ndx + 2 : # No more right neighbour? Then go back the parents until …
346
+ dnam, bnam = os.path.split(dirnam)
347
+ parents = self.__get_listing(dnam)
348
+ ndx = parents.index(bnam)
349
+ x1 = parents[ndx + 1]
350
+ x2 = FileHiker.normaleis(os.path.join(dnam, x1))
351
+ return x2 # 🚀
352
+
353
+ # (.4) … [seq 20260818°0935]
354
+ x1 = childs[ndx + 1]
355
+ x2 = os.path.join(dirnam, x1)
356
+ return x2 # 🚀
357
+
358
+ else :
359
+
360
+ # (.1) No — Get it´s peers [seq 20260818°0937]
361
+ # (.1.1) … [seq 20260818°0939]
362
+ items = self.__get_listing(dir_name) # The peers of current file
363
+ ndx = items.index(base_name)
364
+
365
+ # (.1.2) … [seq 20260818°0941]
366
+ if len(items) > ndx + 1 :
367
+
368
+ # (.1.2.1) Yes — … [seq 20260818°0943]
369
+ # (.1.2.1.1) [seq 20260818°0945]
370
+ nbr1 = os.path.join(dir_name, items[ndx + 1])
371
+ nbr2 = FileHiker.normaleis(nbr1)
372
+
373
+ # (.1.2.1.2) … [condi 20260818°0947]
374
+ if os.path.isdir(nbr2) : # Is folder
375
+
376
+ # (.1) Yes — … [seq 20260818°0949]
377
+ # (.1.1) … [condi 20260818°0951]
378
+ if not recursive : # Empirical try
379
+ return nbr2 # 🚀
380
+
381
+ # (.1.2) … [condi 20260818°0953]
382
+ nbr3 = self.__find_right_neighbouur_4(nbr2, True)
383
+ if nbr3 :
384
+ neighbour = nbr3
385
+ else :
386
+ neighbour = nbr2
387
+
388
+ else :
389
+
390
+ # (.2) No — … [seq 20260818°0955]
391
+ neighbour = nbr2
392
+
393
+ # () … [seq 20260818°0957]
394
+ return neighbour # 🚀
395
+
396
+ else :
397
+
398
+ # (.1.2.2) No — … [seq 20260818°1011]
399
+ # () … [seq 20260818°1013]
400
+ sNext = None
401
+ currdir = dir_name
402
+
403
+ # () … [loop 20260818°1015]
404
+ while not sNext :
405
+
406
+ # () Turn around? [condi 20260818°1017]
407
+ if currdir + '/' == self.get_BasePath() :
408
+ return self.get_BasePath() # 🚀
409
+
410
+ # () Find next from the peers of parents (aunts) [seq 20260818°1021]
411
+ dirnm, basenm = os.path.split(currdir)
412
+ peers = self.__get_listing(dirnm)
413
+ ndx = peers.index(basenm)
414
+
415
+ # () … [seq 20260818°1023]
416
+ if len(peers) > ndx + 1 : # No more right neighbour? Then go back the parents until …
417
+ # () Found … [seq 20260818°1025]
418
+ sNext = FileHiker.normaleis(os.path.join(dirnm, peers[ndx + 1]))
419
+ return sNext # 🚀
420
+ else :
421
+ # () Continue diving … [seq 20260818°1027]
422
+ currdir = dirnm
423
+
424
+ # () Find the index of the given base name in the sorted list [seq 20260818°1031]
425
+ try:
426
+ current_index = sorted_items.index(base_name)
427
+ except ValueError:
428
+ raise ValueError(f"[Err_4129] The item {base_name} does not exist in the directory {dir_name}.")
429
+
430
+ # () Determine the next item [condi 20260818°1033]
431
+ if current_index + 1 < len(sorted_items):
432
+ # () Yes — Determine the next item [seq 20260818°1035]
433
+ # (.1) … [seq 20260818°1036]
434
+ next_item = sorted_items[current_index + 1]
435
+ next_path = os.path.join(dir_name, next_item)
436
+
437
+ # (.2) If the next item is a directory, dive into it [condi 20260818°1037]
438
+ if os.path.isdir(next_path):
439
+ return self.__find_right_neighbouur_3(next_path) # 🚀
440
+ else:
441
+ return next_path # 🚀
442
+ else:
443
+ # () No — … [condi 20260818°1041]
444
+ return None # 🚀 No next item available — Does this ever happen?!
445
+
446
+
447
+ ## \entry Private method 20260806°1731 get_deepest_first_item
448
+ # \brief Helper method — Symmetric to get_deepest_last_item …
449
+ # \param directory — …
450
+ # \return The wanted first item
451
+ def __get_first_folder_item(self, directory) : # Formerly __get_deepest_first_item(self, directory)
452
+
453
+ """
454
+ Recursively finds the <del>deepest</del> first item (file or directory)
455
+ in a directory, traversed in a depth-first, alphabetically sorted manner.
456
+ """
457
+
458
+ # (R.1) Pessimistic predetermination [line 20260806°1733]
459
+ return_value = None
460
+
461
+ # (R.2) … [seq 20260806°1735]
462
+ items = self.__get_listing(directory) # Alphabetically sorted and cleaned from to-ignore items
463
+
464
+ # (R.3) Are items in the folder? [condi 20260806°1741]
465
+ if items :
466
+ # (R.3.1) Yes, the folder has items [seq 20260806°1745]
467
+ # (R.3.1.1) … [seq 20260806°1747]
468
+ first_item = items[0]
469
+ first_item_path = os.path.join(directory, first_item)
470
+
471
+ # (R.3.1.2) … [condi 20260806°1751]
472
+ if os.path.isdir(first_item_path):
473
+ x = self.__get_first_folder_item(first_item_path)
474
+ return_value = x # 🚀 Found
475
+ else :
476
+ return_value = first_item_path # 🚀 Found
477
+ else :
478
+ # (R.3.2) No, the folder is empty [seq 20260806°1743]
479
+ return_value = directory # 🚀 Found. Empty directory, return itself
480
+
481
+ # (R.4) Ready [line 20260806°1753]
482
+ return return_value
483
+
484
+
485
+ ## \entry Private method 20260806°1431 get_deepest_last_item
486
+ # \brief Helper method …
487
+ # \param directory — …
488
+ # \return The wanted last item
489
+ def __get_deepest_last_item(self, directory) :
490
+
491
+ """
492
+ Recursively finds the deepest last item (file or directory) in a directory,
493
+ traversed in a depth-first, alphabetically sorted manner.
494
+ """
495
+
496
+ # (L.1) Pessimistic predetermination [line 20260806°1433]
497
+ return_value = None
498
+
499
+ # (L.2) … [seq 20260806°1435]
500
+ items = self.__get_listing(directory) # Alphabetically sorted and cleaned from to-ignore items
501
+
502
+ # (L.3) Are items in the folder? [condi 20260806°1441]
503
+ if items :
504
+ # (L.3.1) Yes, the folder has items [seq 20260806°1445]
505
+ # (L.3.1.1) … [seq 20260806°1447]
506
+ last_item = items[-1]
507
+ last_item_path = os.path.join(directory, last_item)
508
+
509
+ # (L.3.1.2) … [condi 20260806°1451]
510
+ if os.path.isdir(last_item_path) :
511
+ x = self.__get_deepest_last_item(last_item_path)
512
+ return_value = x # 🚀 Found
513
+ else :
514
+ return_value = last_item_path # 🚀 Found
515
+ else :
516
+ # (L.3.2) No, the folder is empty [seq 20260806°1443]
517
+ return_value = directory # 🚀 Found. Empty directory, return itself
518
+
519
+ # (L.4) Ready [line 20260806°1453]
520
+ return return_value
521
+
522
+
523
+ ## \entry Private method 20260806°1341 get_listing
524
+ # \brief Sort method …
525
+ # \param sDirectory — String with directory of which the listing is wanted …
526
+ # \return Listing sorted and cleaned from ignored items or None, where None is fatal
527
+ # \detail Sorting is made a dedicated function, so we can consistently implement the ignore feature
528
+ # \detail Finding 20260806°1621 — `x = list.sort(key = str.casefold)` yields `x = None´. Explanation: list.sort() does
529
+ # not return a value! Reference https://docs.python.org/3/library/stdtypes.html#list.sort [ref 20260807°1616]
530
+ # says 'This method modifies the sequence in place for economy of space when sorting a large sequence …'
531
+ def __get_listing(self, sDirectory) :
532
+
533
+ # (1) Create sorted list [seq 20260806°1343]
534
+ try :
535
+ items1 = os.listdir(sDirectory)
536
+ except :
537
+ return None
538
+
539
+ items1.sort(key = str.casefold) # Sorting is done 'in place', not returning a new list
540
+
541
+ # (2) Filter out ignored entries [var 20260808°0713]
542
+ items2 = [
543
+ sEntry for sEntry in items1
544
+ if not any(fnmatch.fnmatch(sEntry, pattern) for pattern in self.__lstIgnorePats)
545
+ ]
546
+
547
+ # (3) Finished [line 20260806°1345]
548
+ return items2
549
+
550
+
551
+ ## \entry Private method 20260808°1011
552
+ # \brief Finds the next valid path for an invalid path one
553
+ # \param sPath — The invalid one …
554
+ # \return The next valid path …
555
+ # \detail Expects a string path so far, but might be extended to process a pathlib.Path as well
556
+ # \remark See todo 20260808°1131 'Consolidate invalid path algorithm'
557
+ def __guarantee_valid_pathes(self, s_target_path) :
558
+
559
+ # (1) Tweak input [seq 20260806°1513]
560
+ # (1.1) Make path absolute [seq 20260808°0931]
561
+ sBase_path = os.path.abspath(self.__sBasePath)
562
+ sTarget_path = os.path.abspath(s_target_path)
563
+
564
+ # (1.2) Normalize [seq 20260808°0933] Check — Not yet sure what shall come first, abspath or normalize
565
+ sBase_path = FileHiker.normaleis(sBase_path)
566
+ sTarget_path = FileHiker.normaleis(sTarget_path)
567
+
568
+ # (2) Valid base path is absolute requirement [seq 20260808°1013]
569
+ if not os.path.exists(sBase_path) :
570
+ sMsg = f'[Err_4121] Fatal — Invalid base path: "{sBase_path}"'
571
+ #raise ValueError(sMsg)
572
+ print(sMsg)
573
+ os.sys.exit(66)
574
+
575
+ # (R.2) Paranoia — Guarantee target_path is inside or equal to base_path [condi 20260806°1815]
576
+ if not (sTarget_path.startswith(self.__sBasePath)) :
577
+
578
+ # () Notification
579
+ ###raise ValueError(f"[Err_4133] Target path {sTarget_path} is not inside or equal to base path {self.__sBasePath}")
580
+ sMsg = f'[Wrn_4133] Warning — Target is not inside or equal to base path, it will be fixed to basepath:' \
581
+ + f' • Target given = "{sTarget_path}"' \
582
+ + f' • Target fixed = "{self.__sBasePath}"'
583
+ print(sMsg)
584
+
585
+ # () Brute force correction [line 20260818°0751]
586
+ sTarget_path = self.__sBasePath
587
+
588
+ return self.__sBasePath, sTarget_path # 🚀
589
+
590
+
591
+ # (3) Is it a symlink= [seq 20260808°1021] Space located for later implementation
592
+ # See e.g. https://bobbyhadz.com/blog/python-check-if-file-or-directory-path-is-symbolic-link [ref 20260807°1806]
593
+ if os.path.islink(sTarget_path) :
594
+
595
+ # (3.1) Process symlink= [seq 20260808°1023] Dummy code so far. Don´t know yet what exactly to do then
596
+ sMsg = f'[Msg_4123] Symlink = "{sTarget_path}"'
597
+ print(sMsg)
598
+
599
+ # (4) Fix any wrong target path [seq 20260808°1031] See todo 20260808°1131 'Consolidate invalid path algorithm'
600
+ # See e.g. https://chat.mistral.ai/chat/d5e31f0a-bf1f-404f-ad22-cce3c5757faa [ref 20260802°0920 👻]
601
+ # See e.g. https://www.perplexity.ai/search/0126d5ee-85f6-4758-bcd6-bef53601fcaf [ref 20260802°1312]
602
+ if not os.path.exists(sTarget_path) :
603
+
604
+ sValidPath = None
605
+
606
+ # (4.1) Process invalid parent folder(s) — Quick´n´dirty [seq 20260808°1033]
607
+ # This sequence comes into effect, if one of the parent folders of the target item is invalid.
608
+ # Then it will simple-mindedly search the parents for the first valid folder, doing no
609
+ # interpolation This could be much refined by doing interpolations, looking for existing
610
+ # neighbours of the invalid path element, as does the algorithm below [todo 20260808°1131]
611
+ sSearchDir = os.path.dirname(sTarget_path)
612
+ if not os.path.exists(sSearchDir) :
613
+ while not os.path.exists(sSearchDir) :
614
+ sSearchDir = os.path.dirname(sSearchDir)
615
+ return sBase_path, FileHiker.normaleis(sSearchDir) # 🚀
616
+
617
+ # (4.2) Process invalid path last-element [seq 20260808°1035]
618
+ # Here we really look for the nearest replacement for the invalid item by bisecting the listing.
619
+ # Just so far this is applied only for the 'path last-element', not for any deeper mishap,
620
+ # which were already processed simple-minded above.
621
+ # Note : The 'path last-element' is what os.path.basename() delivers. Just the word 'basename' is
622
+ # not so suited here, since we already talk about the 'base path', which is kind of opposite.
623
+ while True :
624
+
625
+ # (4.2.1) … [seq 20260808°1041]
626
+ sValidEntry = None
627
+ aunts = self.__get_listing(sSearchDir) # Aunts is short for 'aunts and uncles' or 'parent siblings'
628
+ if aunts == None :
629
+ # Continue search in parent folder
630
+ sSearchDir = os.path.dirname(sSearchDir)
631
+ continue
632
+
633
+ # (4.2.2) Find the insertion point for the target_path [seq 20260808°1043]
634
+ sLastElement = os.path.basename(sTarget_path) # basename is last element of path
635
+ #if b_Direction :
636
+ # index = bisect.bisect_left(aunts, sLastElement)
637
+ # index_compare = bisect.bisect_right(aunts, sLastElement)
638
+ #else :
639
+ # index = bisect.bisect_right(aunts, sLastElement) # basename is last element of path
640
+ index = bisect.bisect(aunts, sLastElement) # basename is last element of path
641
+
642
+ # (4.2.3) Determine previous and next paths [seq 20260808°1045]
643
+ if index > 0 :
644
+ sValidEntry = aunts[index - 1]
645
+ elif index < len(aunts) :
646
+ sValidEntry = aunts[index]
647
+ else :
648
+ sMsg = f'[Err_4125] Fatal — Invalid base path: "{sBase_path}"'
649
+ #raise ValueError(sMsg)
650
+ print(sMsg)
651
+ os.sys.exit(66)
652
+
653
+ # (4.2.4) Searching done? [seq 20260808°1047]
654
+ if sValidEntry :
655
+ sTarget_path = os.path.join(os.path.dirname(sTarget_path), sValidEntry)
656
+ break
657
+
658
+ return sBase_path, sTarget_path # 🚀
659
+
660
+
661
+ ## \register Public method 20260810°1131 find_neighbour
662
+ # \brief The two-way walking work horse API method …
663
+ # \detail Just a wrapper method to allow for the API one single method with a direction flag
664
+ # \remark The spelling 'neighbor' is American English, 'neighbour' is British English
665
+ # \param base_path — The root of the search area
666
+ # \param target_path — The item inside root for which the left neighbour is wanted
667
+ # \param b_forward — Flag telling whether to walk forward or backward
668
+ # \return The wanted right or left neighbour
669
+ def find_neighbour(self, s_target_path) :
670
+
671
+ # () Parameter sanitation [line 20260816°0811]
672
+ s_target_path = FileHiker.normaleis(s_target_path)
673
+
674
+ # () Paranoia — Guarantee target_path is inside or equal to base_path [condi 20260816°0751]
675
+ # This shall replace seq 20260806°1515 and seq 20260806°1815
676
+ if not (s_target_path.startswith(self.__sBasePath)) :
677
+
678
+ # () Notify problem [line 20260816°0753] Todo: Implement more graceful notification [todo 20260816°0755]
679
+ sMsg = f'\n\n[Err_4135] Given target path is not inside or equal to base path' \
680
+ + f'\n • BasePath = "{self.__sBasePath}"' \
681
+ + f'\n • Given target = "{s_target_path}"' \
682
+ + f'\n • Fixed target = "{self.__sBasePath}"\n'
683
+ print(sMsg)
684
+
685
+ s_target_path = self.__sBasePath
686
+
687
+ # ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~
688
+ # () Preparations … [seq 20260818°0833] Shifted redundant sequence here from __find_left_neighbour() and __find_left_neighbour() [chg 20260818°0831´01]
689
+ # (L.1) Guarantee valid pathes [seq 20260808°0941]
690
+ sBase_path, sTarget_path = self.__guarantee_valid_pathes(s_target_path)
691
+
692
+ # (L.3) Is target the base path? [seq 20260806°1517]
693
+ if sTarget_path == self.__sBasePath :
694
+ sTgt_FolderName = self.__sBasePath
695
+ sTgt_ElementName = os.path.basename(self.__sBasePath)
696
+ else :
697
+ sTgt_FolderName = os.path.dirname(sTarget_path)
698
+ sTgt_ElementName = os.path.basename(sTarget_path)
699
+ # ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~
700
+
701
+ # () Dispatch the call [seq 20260810°1133]
702
+ if self.__bDirection:
703
+ ###neighbour = self.__find_right_neighbour(sTgt_FolderName, sTgt_ElementName) # [chg 20260818°0831´06]
704
+ x = FileHiker.normaleis(sTgt_FolderName + '/' + sTgt_ElementName)
705
+ neighbour = self.__find_right_neighbouur_4(x)
706
+ else:
707
+ neighbour = self.__find_left_neighbour(sTgt_FolderName, sTgt_ElementName) # [chg 20260818°0831´07]
708
+
709
+ # () Streamline result — Should be superfluous if the callees return values are normalized [line 20260810°1135]
710
+ sNeighbour = FileHiker.normaleis(neighbour)
711
+
712
+ # () Ready
713
+ return sNeighbour
714
+
715
+
716
+ ## \entry Method 20260811°1041 get_BasePath
717
+ # \brief Accessor method …
718
+ # \return The wanted value
719
+ def get_BasePath(self) :
720
+
721
+ return self.__sBasePath
722
+
723
+
724
+ ## \entry Method 20260811°1051 get_Direction
725
+ # \brief Accessor
726
+ # \return The wanted value
727
+ def get_Direction(self) :
728
+
729
+ return self.__bDirection
730
+
731
+
732
+ ## \entry Method 20260811°1121 get_Verbose
733
+ # \brief Accessor
734
+ # \return The wanted value
735
+ def get_Verbose(self) :
736
+
737
+ return self.__bVerbose
738
+
739
+
740
+ ## \entry Method 20260811°1111 get_IgnoreList
741
+ # \brief Accessor
742
+ # \return The wanted value
743
+ def get_IgnoreList(self) :
744
+
745
+ return self.__lstIgnorePats
746
+
747
+
748
+ ## \entry Static method 20260804°0701
749
+ # \brief Helper method …
750
+ # \param path — The path to be normalized, be it a string or pathlib.Path
751
+ # \return String with the normalized path
752
+ @staticmethod
753
+ def normaleis(path) :
754
+
755
+ sPath = str(path) # Convert possible pathlib.Path object
756
+ sPath21 = os.path.normpath(sPath) # Get rid of intermittend dot folders
757
+ sPath22 = sPath21.replace('\\', '/') # Use slashes, not backslashes
758
+ bExists = os.path.exists(sPath)
759
+ sPath23 = sPath22 # Set default
760
+ if bExists and os.path.isdir(sPath22) :
761
+ sPath23 = sPath22 if sPath22.endswith('/') else sPath22 + '/' # Guarantee folder´s trailing slash
762
+
763
+ sPath23 = sPath23[:-1] if sPath23.endswith('//') else sPath23 # Provisory patch against double slashes [line 20260818°1111]
764
+
765
+ return sPath23
766
+
767
+
768
+ ## \entry Method 20260811°1043 set_BasePath
769
+ # \brief Accessor method to set the base path …
770
+ # \param The value to be set
771
+ def set_BasePath(self, sPath) :
772
+
773
+ sPth = FileHiker.normaleis(sPath)
774
+
775
+ self.__sBasePath = sPth
776
+
777
+
778
+ ## \entry Method 20260811°1053 set_Direction
779
+ # \brief Set the direction flag
780
+ # \param bDir — The value wanted to be set
781
+ def set_Direction(self, bDir) :
782
+
783
+ self.__bDirection = bDir
784
+
785
+
786
+ ## \entry Method 20260811°1113 set_IgnoreList
787
+ # \brief Set the ignore list
788
+ # \param The value to be set
789
+ def set_IgnoreList(self, lst) :
790
+
791
+ self.__lstIgnorePats = lst
792
+
793
+
794
+ ## \entry Method 20260811°1123 set_Verbose
795
+ # \brief Set the verbose flag
796
+ # \param The value to be set
797
+ def get_Verbose(self, bVerbose) :
798
+
799
+ self.__bVerbose = bVerbose
800
+
801
+
802
+ ## \entry Public?! method 20260808°0731 — So far only called from __main__.py, thus possibly not private. This method may be superfluous!
803
+ # \brief Strip base path prefix from full path string …
804
+ # \param s_BasePath — …
805
+ # \param s_FullPath — …
806
+ # \return Path string with the base path stripped
807
+ def strip_prefix(self, s_BasePath, s_FullPath) :
808
+
809
+ sBasePath = s_BasePath.replace('\\', '/') # Paranoia
810
+ sFullPath = s_FullPath.replace('\\', '/')
811
+ sReturn = sFullPath # Pessimistic default
812
+
813
+ if sFullPath.startswith(sBasePath) :
814
+ sReturn = sFullPath[len(s_BasePath):]
815
+
816
+ return sReturn
817
+
818
+
819
+ ## = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = =
820
+ ## Former functionality code shutdown in favour of class 20260810°0911 FileHiker [chg 20260810°1051 '💫 Breaking change']
821
+ ### = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = =
822
+
823
+ ## = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = =
824
+ ## Seftest functions shifted from here to module __main__.py [chg 20260811°0811]
825
+ ### = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = =
826
+
827
+
828
+ ## \register seq 20250406°0731
829
+ # \brief Main idiom — The plain `if __name__ == '__main__'` does not work as usual because the caller here is __main__.py
830
+ if __name__ == "__main__" and __package__ not in (None, ''):
831
+
832
+ print('[Msg_4127] This package module shall not be started directly.')
833
+ sys.exit(123) # Runs without any ado in a commandline call, throws exception `SystemExit` in the debugger