Consider the following snippet:
import inspect from pathlib import Path def path_relative_to_caller_file(*pathparts: str) -> Path: """Provides a new path as a combination of the caller's directory and a subpath. When creating a Path like Path("resources", "log.txt"), the containing Python script has to be called from within the directory where the subdirectory "resources" is found. Otherwise, the relative path breaks with an error. This function provides a new path to always assure those relative paths are found, no matter from where the script containing the relative path definition is called. Example: A/ └── B/ └── C/ ├── script.py └── D/ └── E/ └── config.txt File script.py contains a Path("D", "E", "config.txt") to access data in that config file. If the script is called from the "C" directory, the relative path resolves fine, since the "D" subdirectory is immediately visible. However, if the script is called from e.g. "A", it breaks, because there is no "D/E/config.txt" in "A". If the script uses this function instead, the paths get resolved correctly to absolute paths. Here, Path("D", "E", "config.txt") is the 'subpath'. Note: this function can also be called without an argument to get the caller's file's containing directory, or with ".." (as many as needed) to move up. If called with one argument, a file name, this script can replace `Path(__file__).with_name("new_file")` to get a new file path in the same directory as the caller's file, while being much clearer in syntax. Attributes: pathparts: As many arguments as the subpath needs. The new Path will be created as e.g. Path("x", "y", "z"). This gets rid of ambiguities surrounding usage of forward or backward slashes. """ current_frame = inspect.currentframe() # This function's frame previous_frame = current_frame.f_back # One up: the caller's frame caller_filename = inspect.getsourcefile(previous_frame) # Construct a Path relative to the caller's directory: caller_dir = Path(caller_filename).parent sub_path = Path(*pathparts) # Can be anything: file, dir, links return caller_dir.joinpath(sub_path).resolve()
The docstring explains it in detail. The synopsis is: there is a Python file, here
script.py, that relies on a file that is found in a fixed location relative to it. In this case,
D/E/config.txt. It can be any relative path, including
script.py file can be called from anywhere. If it is called from anywhere but the
C/ directory, the discovery for
config.txt can break easily with many naive approaches.
As such, a
script.py file can import
path_relative_to_caller_file. It can do so from wherever, that part should not matter. The function fully resolves paths relative to the file from which it is called.
The directory structure is as follows:
~$ tree A A └── B └── C ├── D │ └── E │ └── config.txt ├── path_relative_to_caller_file.py └── script.py
path_relative_to_caller_file.py contains only the code shown above.
from pathlib import Path from path_relative_to_caller_file import path_relative_to_caller_file paths = [ path_relative_to_caller_file(), path_relative_to_caller_file(".."), path_relative_to_caller_file("..", ".."), path_relative_to_caller_file("D"), path_relative_to_caller_file("D", "E"), path_relative_to_caller_file("D", "E", "config.txt"), path_relative_to_caller_file("nonexistent_directory"), ] plain_path = Path("D", "E", "config.txt") print("path_relative_to_caller_file:") for path in paths: print("\t", path, path.exists()) print("Plain path in script.py:") print("\t", plain_path, plain_path.exists())
This works if called from the parent directory of
~$ python3 A/B/C/script.py path_relative_to_caller_file: /home/hansA/B/C True /home/hans/A/B True /home/hans/A True /home/hans/A/B/C/D True /home/hans/A/B/C/D/E True /home/hans/A/B/C/D/E/config.txt True /home/hans/A/B/C/nonexistent_directory False Plain path in script.py: D/E/config.txt False
The plain, "naive" approach works only if called from
~/A/B/C$ python3 script.py path_relative_to_caller_file: /home/hansA/B/C True /home/hans/A/B True /home/hans/A True /home/hans/A/B/C/D True /home/hans/A/B/C/D/E True /home/hans/A/B/C/D/E/config.txt True /home/hans/A/B/C/nonexistent_directory False Plain path in script.py: D/E/config.txt True
The above approach keeps working however. It even works when navigating up the tree:
~/A/B/C/D/E$ python3 ../../script.py path_relative_to_caller_file: /home/hans/A/B/C True /home/hans/A/B True /home/hans/A True /home/hans/A/B/C/D True /home/hans/A/B/C/D/E True /home/hans/A/B/C/D/E/config.txt True /home/hans/A/B/C/nonexistent_directory False Plain path in script.py: D/E/config.txt False
inspect module seems a bit overkill for this. I also wonder about security (can the frame be injected maliciously by the caller?) and performance (an entire inspection for what is not much more than some string-fu) issues. When looking at the problem at a distance, it seems like there should be an easier solution.
It is also possible I got this entirely backwards and am missing the bigger picture.
An obvious alternative would be to just have a function that requires a
Path object to do the relative work on. Callers of that function would then just pass their
__file__ variable (part of
globals()), followed by the same
*pathargs that will work relative on that
__file__ path and return the (resolved) result. This would be straighforward. In fact, this is how I had it at first. However, then all function calls have
__file__ as their first argument. As such, I came up with the above to rid the code of this perceived redundancy (DRY).
Tested on Python 3.7.7 (Debian) and Python 3.8.2 (Win10).