Skip to content

Commit 86054f1

Browse files
committed
Merge pull request #7 from quantopian/rename-directories-changes
Rename directories changes
2 parents 0310ace + 6778e6d commit 86054f1

File tree

4 files changed

+158
-133
lines changed

4 files changed

+158
-133
lines changed

pgcontents/pgmanager.py

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -51,14 +51,16 @@
5151
)
5252
from .managerbase import PostgresManagerMixin
5353
from .query import (
54-
delete_file,
5554
delete_directory,
55+
delete_file,
5656
dir_exists,
5757
ensure_directory,
58+
file_exists,
5859
get_directory,
5960
get_file,
6061
purge_user,
61-
rename,
62+
rename_directory,
63+
rename_file,
6264
save_file,
6365
)
6466

@@ -139,11 +141,7 @@ def is_hidden(self, path):
139141
@outside_root_to_404
140142
def file_exists(self, path):
141143
with self.engine.begin() as db:
142-
try:
143-
get_file(db, self.user_id, path, include_content=False)
144-
return True
145-
except NoSuchFile:
146-
return False
144+
return file_exists(db, self.user_id, path)
147145

148146
@outside_root_to_404
149147
def get(self, path, content=True, type=None, format=None):
@@ -349,10 +347,18 @@ def save(self, model, path):
349347
def rename_file(self, old_path, path):
350348
"""
351349
Rename object from old_path to path.
350+
351+
NOTE: This method is unfortunately named on the base class. It
352+
actually moves a file or a directory.
352353
"""
353354
with self.engine.begin() as db:
354355
try:
355-
rename(db, self.user_id, old_path, path)
356+
if self.file_exists(old_path):
357+
rename_file(db, self.user_id, old_path, path)
358+
elif self.dir_exists(old_path):
359+
rename_directory(db, self.user_id, old_path, path)
360+
else:
361+
self.no_such_entity(path)
356362
except (FileExists, DirectoryExists):
357363
self.already_exists(path)
358364

pgcontents/query.py

Lines changed: 59 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -349,12 +349,15 @@ def file_exists(db, user_id, path):
349349
return False
350350

351351

352-
def rename(db, user_id, old_api_path, new_api_path):
352+
def rename_file(db, user_id, old_api_path, new_api_path):
353353
"""
354-
Rename a file or a directory.
355-
356-
TODO: Don't do anything if paths are the same.
354+
Rename a file.
357355
"""
356+
357+
# Overwriting existing files is disallowed.
358+
if file_exists(db, user_id, new_api_path):
359+
raise FileExists(new_api_path)
360+
358361
old_dir, old_name = split_api_filepath(old_api_path)
359362
new_dir, new_name = split_api_filepath(new_api_path)
360363
if old_dir != new_dir:
@@ -371,74 +374,69 @@ def rename(db, user_id, old_api_path, new_api_path):
371374
)
372375
)
373376

374-
# Only allow writes to extant directories
375-
if not _dir_exists(db, user_id, new_dir):
376-
raise NoSuchDirectory(new_dir)
377-
378-
# If we're renaming a file
379-
if file_exists(db, user_id, old_api_path):
380-
# Overwriting existing files is disallowed.
381-
if file_exists(db, user_id, new_api_path):
382-
raise FileExists(new_api_path)
383-
384-
db.execute(
385-
files.update().where(
386-
_file_where(user_id, old_api_path),
387-
).values(
388-
name=new_name,
389-
created_at=func.now(),
390-
)
377+
db.execute(
378+
files.update().where(
379+
_file_where(user_id, old_api_path),
380+
).values(
381+
name=new_name,
382+
created_at=func.now(),
391383
)
384+
)
392385

393-
# If we're renaming a directory
386+
387+
def rename_directory(db, user_id, old_api_path, new_api_path):
388+
"""
389+
Rename a directory.
390+
"""
394391
old_db_path = from_api_dirname(old_api_path)
395392
new_db_path = from_api_dirname(new_api_path)
396-
if _dir_exists(db, user_id, old_db_path):
397-
# Overwriting existing directories is disallowed.
398-
if _dir_exists(db, user_id, new_db_path):
399-
raise DirectoryExists(new_api_path)
400393

401-
# Set this foreign key constraint to deferred so it's not violated
402-
# when we run the first statement to update the name of the directory.
403-
db.execute('SET CONSTRAINTS '
404-
'pgcontents.directories_parent_user_id_fkey DEFERRED')
394+
# Overwriting existing directories is disallowed.
395+
if _dir_exists(db, user_id, new_db_path):
396+
raise DirectoryExists(new_api_path)
405397

406-
# Update name column for the directory that's being renamed
407-
db.execute(
408-
directories.update().where(
409-
and_(
410-
directories.c.user_id == user_id,
411-
directories.c.name == old_db_path,
412-
)
413-
).values(
414-
name=new_db_path,
398+
# Set this foreign key constraint to deferred so it's not violated
399+
# when we run the first statement to update the name of the directory.
400+
db.execute('SET CONSTRAINTS '
401+
'pgcontents.directories_parent_user_id_fkey DEFERRED')
402+
403+
# Update name column for the directory that's being renamed
404+
db.execute(
405+
directories.update().where(
406+
and_(
407+
directories.c.user_id == user_id,
408+
directories.c.name == old_db_path,
415409
)
410+
).values(
411+
name=new_db_path,
416412
)
413+
)
417414

418-
# Update the name and parent_name of any descendant directories. Do
419-
# this in a single statement so the non-deferrable check constraint
420-
# is satisfied.
421-
db.execute(
422-
directories.update().where(
423-
and_(
424-
directories.c.user_id == user_id,
425-
directories.c.name.startswith(old_db_path),
426-
directories.c.parent_name.startswith(old_db_path),
427-
)
428-
).values(
429-
name=func.concat(
430-
new_db_path,
431-
func.right(directories.c.name, -func.length(old_db_path))
432-
),
433-
parent_name=func.concat(
434-
new_db_path,
435-
func.right(
436-
directories.c.parent_name,
437-
-func.length(old_db_path)
438-
)
439-
),
415+
# Update the name and parent_name of any descendant directories. Do
416+
# this in a single statement so the non-deferrable check constraint
417+
# is satisfied.
418+
db.execute(
419+
directories.update().where(
420+
and_(
421+
directories.c.user_id == user_id,
422+
directories.c.name.startswith(old_db_path),
423+
directories.c.parent_name.startswith(old_db_path),
440424
)
425+
).values(
426+
name=func.concat(
427+
new_db_path,
428+
func.right(directories.c.name, -func.length(old_db_path))
429+
),
430+
parent_name=func.concat(
431+
new_db_path,
432+
func.right(
433+
directories.c.parent_name,
434+
-func.length(old_db_path)
435+
)
436+
),
441437
)
438+
)
439+
442440

443441
def check_content(content, max_size_bytes):
444442
"""

pgcontents/tests/test_pgmanager.py

Lines changed: 82 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
from __future__ import unicode_literals
1919

2020
from base64 import b64encode
21+
from itertools import combinations
2122

2223
from IPython.html.services.contents.tests.test_manager import TestContentsManager # noqa
2324

@@ -58,6 +59,43 @@ def make_dir(self, api_path):
5859
path=api_path,
5960
)
6061

62+
def make_populated_dir(self, api_path):
63+
"""
64+
Create a directory at api_path with a notebook and a text file.
65+
"""
66+
self.make_dir(api_path)
67+
self.contents_manager.new(
68+
path='/'.join([api_path, 'nb.ipynb'])
69+
)
70+
self.contents_manager.new(
71+
path='/'.join([api_path, 'file.txt'])
72+
)
73+
74+
def check_populated_dir_files(self, api_path):
75+
"""
76+
Check that a directory created with make_populated_dir has a
77+
notebook and a text file with expected names.
78+
"""
79+
dirmodel = self.contents_manager.get(api_path)
80+
self.assertEqual(dirmodel['path'], api_path)
81+
self.assertEqual(dirmodel['type'], 'directory')
82+
for entry in dirmodel['content']:
83+
# Skip any subdirectories created after the fact.
84+
if entry['type'] == 'directory':
85+
continue
86+
elif entry['type'] == 'file':
87+
self.assertEqual(entry['name'], 'file.txt')
88+
self.assertEqual(
89+
entry['path'],
90+
'/'.join([api_path, 'file.txt']),
91+
)
92+
elif entry['type'] == 'notebook':
93+
self.assertEqual(entry['name'], 'nb.ipynb')
94+
self.assertEqual(
95+
entry['path'],
96+
'/'.join([api_path, 'nb.ipynb']),
97+
)
98+
6199
def tearDown(self):
62100
drop_testing_db_tables()
63101
migrate_testing_db()
@@ -85,77 +123,59 @@ def test_modified_date(self):
85123
self.assertGreater(renamed['last_modified'], saved['last_modified'])
86124

87125
def test_rename_directory(self):
126+
"""
127+
Create a directory hierarchy that looks like:
128+
129+
foo/
130+
...
131+
bar/
132+
...
133+
foo/
134+
...
135+
bar/
136+
...
137+
bar/
138+
139+
then rename /foo/bar -> /foo/bar_changed and verify that all changes
140+
propagate correctly.
141+
"""
88142
cm = self.contents_manager
89143

90-
# Create an untitled directory
91-
foo_dir = cm.new_untitled(type='directory')
92-
old_foo_dir_path = foo_dir['path']
144+
all_dirs = ['foo', 'bar', 'foo/bar', 'foo/bar/foo', 'foo/bar/foo/bar']
145+
unchanged_dirs = all_dirs[:2]
146+
changed_dirs = all_dirs[2:]
93147

94-
# Change the path on the model and call cm.update to rename
95-
foo_dir_path = 'foo'
96-
foo_dir['path'] = foo_dir_path
97-
foo_dir = cm.update(foo_dir, old_foo_dir_path)
148+
for dir_ in all_dirs:
149+
self.make_populated_dir(dir_)
150+
self.check_populated_dir_files(dir_)
98151

99-
# Check that the cm.update returns a model
100-
assert isinstance(foo_dir, dict)
152+
# Renaming to an extant directory should raise
153+
for src, dest in combinations(all_dirs, 2):
154+
with assertRaisesHTTPError(self, 409):
155+
cm.rename(src, dest)
101156

102-
# Make sure the untitled directory is gone
103-
self.assertRaises(HTTPError, cm.get, old_foo_dir_path)
157+
# Verify that we can't create a new notebook in the (nonexistent)
158+
# target directory
159+
with assertRaisesHTTPError(self, 404):
160+
cm.new_untitled('foo/bar_changed', ext='.ipynb')
104161

105-
# Create a subdirectory
106-
bar_dir = cm.new(
107-
model={'type': 'directory'},
108-
path='foo/bar',
109-
)
110-
old_bar_dir_path = bar_dir['path']
162+
cm.rename('foo/bar', 'foo/bar_changed')
111163

112-
# Create a file in the subdirectory
113-
bar_file = cm.new_untitled(path='foo/bar', type='notebook')
114-
old_bar_file_path = bar_file['path']
164+
# foo/ and bar/ should be unchanged
165+
for unchanged in unchanged_dirs:
166+
self.check_populated_dir_files(unchanged)
115167

116-
# Create another subdirectory one level deeper. Use 'foo' for the name
117-
# again to catch issues with replacing all instances of a substring
118-
# instead of just the first.
119-
bar2_dir = cm.new(
120-
model={'type': 'directory'},
121-
path='foo/bar/bar',
122-
)
123-
old_bar2_dir_path = bar2_dir['path']
124-
125-
# Create a file in the two-level deep directory we just created
126-
bar2_file = cm.new_untitled(path=old_bar2_dir_path, type='notebook')
127-
old_bar2_file_path = bar2_file['path']
128-
129-
# Change the path of the first bar directory
130-
new_bar_dir_path = 'foo/bar_changed'
131-
bar_dir['path'] = new_bar_dir_path
132-
bar_dir = cm.update(bar_dir, old_bar_dir_path)
133-
self.assertIn('name', bar_dir)
134-
self.assertIn('path', bar_dir)
135-
self.assertEqual(bar_dir['name'], 'bar_changed')
136-
137-
# Make sure calling cm.get on any old paths throws an exception
138-
self.assertRaises(HTTPError, cm.get, old_bar_dir_path)
139-
self.assertRaises(HTTPError, cm.get, old_bar2_dir_path)
140-
self.assertRaises(HTTPError, cm.get, old_bar_file_path)
141-
self.assertRaises(HTTPError, cm.get, old_bar2_file_path)
142-
143-
def try_get_new_path(full_old_path):
144-
# replace the first occurence of the old path with the new one
145-
new_path = full_old_path.replace(
146-
old_bar_dir_path,
147-
new_bar_dir_path,
148-
1
168+
# foo/bar/ and subdirectories should have leading prefixes changed
169+
for changed_dirname in changed_dirs:
170+
with assertRaisesHTTPError(self, 404):
171+
cm.get(changed_dirname)
172+
new_dirname = changed_dirname.replace(
173+
'foo/bar', 'foo/bar_changed', 1
149174
)
150-
new_model = cm.get(new_path)
151-
self.assertIn('name', new_model)
152-
self.assertIn('path', new_model)
153-
154-
# Make sure the directories and files can be found at their new paths
155-
try_get_new_path(foo_dir_path) # top level foo dir should be unchanged
156-
try_get_new_path(old_bar_file_path)
157-
try_get_new_path(old_bar2_dir_path)
158-
try_get_new_path(old_bar2_file_path)
175+
self.check_populated_dir_files(new_dirname)
176+
177+
# Verify that we can now create a new notebook in the changed directory
178+
cm.new_untitled('foo/bar_changed', ext='.ipynb')
159179

160180
def test_max_file_size(self):
161181

setup.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,15 +25,16 @@ def main():
2525

2626
setup(
2727
name='pgcontents',
28-
version='0.0.2',
28+
version='0.1',
2929
description="A Postgres-backed ContentsManager for IPython.",
3030
author="Scott Sanderson",
3131
author_email="[email protected]",
3232
packages=[
3333
'pgcontents',
34-
'pgcontents/utils/',
3534
'pgcontents/alembic',
3635
'pgcontents/alembic/versions',
36+
'pgcontents/tests/',
37+
'pgcontents/utils/',
3738
],
3839
license='Apache 2.0',
3940
include_package_data=True,

0 commit comments

Comments
 (0)