Skip to content

Commit 130cd88

Browse files
committed
Adds ability to recursively create missing directories when writing files pandas-dev#24306
1 parent 902be02 commit 130cd88

File tree

2 files changed

+49
-0
lines changed

2 files changed

+49
-0
lines changed

pandas/io/common.py

+36
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,9 @@ class IOArgs:
7272
compression: CompressionDict
7373
should_close: bool = False
7474

75+
def __post_init__(self):
76+
self.parent_exists = dir_exists(self.filepath_or_buffer)
77+
7578

7679
@dataclasses.dataclass
7780
class IOHandles:
@@ -630,6 +633,17 @@ def get_handle(
630633
compression_args = dict(ioargs.compression)
631634
compression = compression_args.pop("method")
632635

636+
# If the parent directory doesn't exist initializing the stream will fail (GH 24306)
637+
if (
638+
_is_writable_mode(mode)
639+
and is_path
640+
and not ioargs.parent_exists
641+
):
642+
os.makedirs(
643+
os.path.dirname(ioargs.filepath_or_buffer),
644+
exist_ok=True,
645+
)
646+
633647
if compression:
634648
# compression libraries do not like an explicit text-mode
635649
ioargs.mode = ioargs.mode.replace("t", "")
@@ -937,6 +951,20 @@ def file_exists(filepath_or_buffer: FilePathOrBuffer) -> bool:
937951
return exists
938952

939953

954+
def dir_exists(filepath_or_buffer: FilePathOrBuffer) -> bool:
955+
"""Test whether parent directory exists."""
956+
exists = False
957+
filepath_or_buffer = stringify_path(filepath_or_buffer)
958+
if not isinstance(filepath_or_buffer, str):
959+
return exists
960+
try:
961+
exists = os.path.exists(os.path.dirname(filepath_or_buffer))
962+
# gh-5874: if the filepath is too long will raise here
963+
except (TypeError, ValueError):
964+
pass
965+
return exists
966+
967+
940968
def _is_binary_mode(handle: FilePathOrBuffer, mode: str) -> bool:
941969
"""Whether the handle is opened in binary mode"""
942970
# specified by user
@@ -951,3 +979,11 @@ def _is_binary_mode(handle: FilePathOrBuffer, mode: str) -> bool:
951979
# classes that expect bytes
952980
binary_classes = (BufferedIOBase, RawIOBase)
953981
return isinstance(handle, binary_classes) or "b" in getattr(handle, "mode", mode)
982+
983+
984+
def _is_writable_mode(mode: str) -> bool:
985+
"""Whether the handle is opened in writable mode"""
986+
writable_prefixes = ('a', 'w', 'r+')
987+
if any(map(mode.startswith, writable_prefixes)):
988+
return True
989+
return False

pandas/tests/io/test_common.py

+13
Original file line numberDiff line numberDiff line change
@@ -533,3 +533,16 @@ def test_errno_attribute():
533533
with pytest.raises(FileNotFoundError, match="\\[Errno 2\\]") as err:
534534
pd.read_csv("doesnt_exist")
535535
assert err.errno == errno.ENOENT
536+
537+
538+
def test_create_missing_dirs():
539+
# GH 24306
540+
df = tm.makeDataFrame()
541+
filepath = 'nonexistent/path/to/file.csv'
542+
df.to_csv(filepath)
543+
assert os.path.exists(filepath)
544+
# Cleanup after test:
545+
os.remove(filepath)
546+
components = filepath.split('/')
547+
for i in reversed(range(1, len(components))):
548+
os.rmdir(os.path.join(*components[:i]))

0 commit comments

Comments
 (0)