Skip to content

Commit

Permalink
Add utility function QgsFileUtils.renameDataset
Browse files Browse the repository at this point in the history
Renames a dataset file, including all its associated sidecar
files (and optionally the .qmd/.qml sidecars)
  • Loading branch information
nyalldawson committed Aug 10, 2021
1 parent 896bab4 commit 1608547
Show file tree
Hide file tree
Showing 7 changed files with 227 additions and 1 deletion.
6 changes: 6 additions & 0 deletions python/core/auto_additions/qgis.py
Expand Up @@ -634,3 +634,9 @@
Qgis.SpatialFilterType.__doc__ = 'Feature request spatial filter types.\n\n.. versionadded:: 3.22\n\n' + '* ``NoFilter``: ' + Qgis.SpatialFilterType.NoFilter.__doc__ + '\n' + '* ``BoundingBox``: ' + Qgis.SpatialFilterType.BoundingBox.__doc__ + '\n' + '* ``DistanceWithin``: ' + Qgis.SpatialFilterType.DistanceWithin.__doc__
# --
Qgis.SpatialFilterType.baseClass = Qgis
# monkey patching scoped based enum
Qgis.FileOperationFlag.IncludeMetadataFile.__doc__ = "Indicates that any associated .qmd metadata file should be included with the operation"
Qgis.FileOperationFlag.IncludeStyleFile.__doc__ = "Indicates that any associated .qml styling file should be included with the operation"
Qgis.FileOperationFlag.__doc__ = 'File operation flags.\n\n.. versionadded:: 3.22\n\n' + '* ``IncludeMetadataFile``: ' + Qgis.FileOperationFlag.IncludeMetadataFile.__doc__ + '\n' + '* ``IncludeStyleFile``: ' + Qgis.FileOperationFlag.IncludeStyleFile.__doc__
# --
Qgis.FileOperationFlag.baseClass = Qgis
10 changes: 10 additions & 0 deletions python/core/auto_generated/qgis.sip.in
Expand Up @@ -444,6 +444,14 @@ The development version
DistanceWithin,
};

enum class FileOperationFlag
{
IncludeMetadataFile,
IncludeStyleFile,
};
typedef QFlags<Qgis::FileOperationFlag> FileOperationFlags;


static const double DEFAULT_SEARCH_RADIUS_MM;

static const float DEFAULT_MAPTOPIXEL_THRESHOLD;
Expand Down Expand Up @@ -527,6 +535,8 @@ QFlags<Qgis::BabelCommandFlag> operator|(Qgis::BabelCommandFlag f1, QFlags<Qgis:

QFlags<Qgis::GeometryValidityFlag> operator|(Qgis::GeometryValidityFlag f1, QFlags<Qgis::GeometryValidityFlag> f2);

QFlags<Qgis::FileOperationFlag> operator|(Qgis::FileOperationFlag f1, QFlags<Qgis::FileOperationFlag> f2);




Expand Down
26 changes: 26 additions & 0 deletions python/core/auto_generated/qgsfileutils.sip.in
Expand Up @@ -152,6 +152,32 @@ and .prj files would be returned (amongst others).
QGIS metadata files (.qmd) and map layer styling files (.qml) are NOT included
in the returned list.

.. versionadded:: 3.22
%End

static bool renameDataset( const QString &oldPath, const QString &newPath, QString &error /Out/, Qgis::FileOperationFlags flags = Qgis::FileOperationFlag::IncludeMetadataFile | Qgis::FileOperationFlag::IncludeStyleFile );
%Docstring
Renames the dataset at ``oldPath`` to ``newPath``, renaming both the file at ``oldPath`` and
all associated sidecar files which exist for it.

For instance, if ``oldPath`` specified a .shp file then the corresponding .dbf, .idx
and .prj files would be renamed (amongst others).

The destination directory must already exist.

The optional ``flags`` argument can be used to control whether QMD metadata files and
QML styling files should also be renamed accordingly. By default these will be renamed,
but manually specifying a different set of flags allows callers to avoid this when
desired.

:param oldPath: original path to dataset
:param newPath: new path for dataset
:param flags: optional flags to control file operation behavior

:return: - ``True`` if the dataset was successfully renamed, or ``False`` if an error occurred
- error: will be set to a descriptive error message if the rename operation fails


.. versionadded:: 3.22
%End

Expand Down
14 changes: 14 additions & 0 deletions src/core/qgis.h
Expand Up @@ -693,6 +693,19 @@ class CORE_EXPORT Qgis
};
Q_ENUM( SpatialFilterType )

/**
* File operation flags.
*
* \since QGIS 3.22
*/
enum class FileOperationFlag : int
{
IncludeMetadataFile = 1 << 0, //!< Indicates that any associated .qmd metadata file should be included with the operation
IncludeStyleFile = 1 << 1, //!< Indicates that any associated .qml styling file should be included with the operation
};
Q_DECLARE_FLAGS( FileOperationFlags, FileOperationFlag )
Q_ENUM( FileOperationFlag )

/**
* Identify search radius in mm
* \since QGIS 2.3
Expand Down Expand Up @@ -815,6 +828,7 @@ Q_DECLARE_OPERATORS_FOR_FLAGS( Qgis::SqlLayerDefinitionCapabilities )
Q_DECLARE_OPERATORS_FOR_FLAGS( Qgis::BabelFormatCapabilities )
Q_DECLARE_OPERATORS_FOR_FLAGS( Qgis::BabelCommandFlags )
Q_DECLARE_OPERATORS_FOR_FLAGS( Qgis::GeometryValidityFlags )
Q_DECLARE_OPERATORS_FOR_FLAGS( Qgis::FileOperationFlags )

// hack to workaround warnings when casting void pointers
// retrieved from QLibrary::resolve to function pointers.
Expand Down
72 changes: 72 additions & 0 deletions src/core/qgsfileutils.cpp
Expand Up @@ -389,3 +389,75 @@ QSet<QString> QgsFileUtils::sidecarFilesForPath( const QString &path )
}
return res;
}

bool QgsFileUtils::renameDataset( const QString &oldPath, const QString &newPath, QString &error, Qgis::FileOperationFlags flags )
{
if ( !QFile::exists( oldPath ) )
{
error = QObject::tr( "File does not exist" );
return false;
}

const QFileInfo oldPathInfo( oldPath );
QSet< QString > sidecars = sidecarFilesForPath( oldPath );
if ( flags & Qgis::FileOperationFlag::IncludeMetadataFile )
{
const QString qmdPath = oldPathInfo.dir().filePath( oldPathInfo.completeBaseName() + QStringLiteral( ".qmd" ) );
if ( QFile::exists( qmdPath ) )
sidecars.insert( qmdPath );
}
if ( flags & Qgis::FileOperationFlag::IncludeStyleFile )
{
const QString qmlPath = oldPathInfo.dir().filePath( oldPathInfo.completeBaseName() + QStringLiteral( ".qml" ) );
if ( QFile::exists( qmlPath ) )
sidecars.insert( qmlPath );
}

const QFileInfo newPathInfo( newPath );

bool res = true;
QStringList errors;
errors.reserve( sidecars.size() );
// first check if all sidecars CAN be renamed -- we don't want to get partly through the rename and then find a clash
for ( const QString &sidecar : std::as_const( sidecars ) )
{
const QFileInfo sidecarInfo( sidecar );
const QString newSidecarName = newPathInfo.dir().filePath( newPathInfo.completeBaseName() + '.' + sidecarInfo.suffix() );
if ( newSidecarName != sidecar && QFile::exists( newSidecarName ) )
{
res = false;
errors.append( QDir::toNativeSeparators( newSidecarName ) );
}
}
if ( !res )
{
error = QObject::tr( "Destination files already exist %1" ).arg( errors.join( QStringLiteral( ", " ) ) );
return false;
}

if ( !QFile::rename( oldPath, newPath ) )
{
error = QObject::tr( "Could not rename %1" ).arg( QDir::toNativeSeparators( oldPath ) );
return false;
}

for ( const QString &sidecar : std::as_const( sidecars ) )
{
const QFileInfo sidecarInfo( sidecar );
const QString newSidecarName = newPathInfo.dir().filePath( newPathInfo.completeBaseName() + '.' + sidecarInfo.suffix() );
if ( newSidecarName == sidecar )
continue;

if ( !QFile::rename( sidecar, newSidecarName ) )
{
errors.append( QDir::toNativeSeparators( sidecar ) );
res = false;
}
}
if ( !res )
{
error = QObject::tr( "Could not rename %1" ).arg( errors.join( QStringLiteral( ", " ) ) );
}

return res;
}
25 changes: 25 additions & 0 deletions src/core/qgsfileutils.h
Expand Up @@ -155,6 +155,31 @@ class CORE_EXPORT QgsFileUtils
*/
static QSet< QString > sidecarFilesForPath( const QString &path );

/**
* Renames the dataset at \a oldPath to \a newPath, renaming both the file at \a oldPath and
* all associated sidecar files which exist for it.
*
* For instance, if \a oldPath specified a .shp file then the corresponding .dbf, .idx
* and .prj files would be renamed (amongst others).
*
* The destination directory must already exist.
*
* The optional \a flags argument can be used to control whether QMD metadata files and
* QML styling files should also be renamed accordingly. By default these will be renamed,
* but manually specifying a different set of flags allows callers to avoid this when
* desired.
*
* \param oldPath original path to dataset
* \param newPath new path for dataset
* \param error will be set to a descriptive error message if the rename operation fails
* \param flags optional flags to control file operation behavior
*
* \returns TRUE if the dataset was successfully renamed, or FALSE if an error occurred
*
* \since QGIS 3.22
*/
static bool renameDataset( const QString &oldPath, const QString &newPath, QString &error SIP_OUT, Qgis::FileOperationFlags flags = Qgis::FileOperationFlag::IncludeMetadataFile | Qgis::FileOperationFlag::IncludeStyleFile );

};

#endif // QGSFILEUTILS_H
75 changes: 74 additions & 1 deletion tests/src/python/test_qgsfileutils.py
Expand Up @@ -10,11 +10,16 @@
__date__ = '18/12/2017'
__copyright__ = 'Copyright 2017, The QGIS Project'

import shutil

import qgis # NOQA

import tempfile
import os
from qgis.core import QgsFileUtils
from qgis.core import (
Qgis,
QgsFileUtils
)
from qgis.testing import unittest
from utilities import unitTestDataPath

Expand Down Expand Up @@ -218,6 +223,74 @@ def test_sidecar_files_for_path(self):
self.assertEqual(QgsFileUtils.sidecarFilesForPath(f'{unitTestDataPath()}/ALLINGES_RGF93_CC46_1_1.tif'),
{f'{unitTestDataPath()}/ALLINGES_RGF93_CC46_1_1.tfw'})

def test_rename_dataset(self):
"""
Test QgsFileUtils.renameDataset
"""
base_path = tempfile.mkdtemp()
for ext in ['shp', 'dbf', 'prj', 'qml', 'shx']:
shutil.copy(f'{unitTestDataPath()}/lines.{ext}', f'{base_path}/lines.{ext}')
shutil.copy(f'{unitTestDataPath()}/lines.{ext}', f'{base_path}/lines.{ext}')
self.assertTrue(os.path.exists(f'{base_path}/lines.shp'))

res, error = QgsFileUtils.renameDataset(f'{base_path}/lines.shp', f'{base_path}/other_lines.shp')
self.assertTrue(res)
for ext in ['shp', 'dbf', 'prj', 'qml', 'shx']:
self.assertTrue(os.path.exists(f'{base_path}/other_lines.{ext}'))
self.assertFalse(os.path.exists(f'{base_path}/lines.{ext}'))

# skip qml file
res, error = QgsFileUtils.renameDataset(f'{base_path}/other_lines.shp', f'{base_path}/other_lines2.shp', Qgis.FileOperationFlags())
self.assertTrue(res)
for ext in ['shp', 'dbf', 'prj', 'shx']:
self.assertTrue(os.path.exists(f'{base_path}/other_lines2.{ext}'))
self.assertFalse(os.path.exists(f'{base_path}/other_lines.{ext}'))
self.assertFalse(os.path.exists(f'{base_path}/other_lines2.qml'))
self.assertTrue(os.path.exists(f'{base_path}/other_lines.qml'))

# try changing extension -- sidecars won't be renamed
res, error = QgsFileUtils.renameDataset(f'{base_path}/other_lines2.shp', f'{base_path}/other_lines2.txt',
Qgis.FileOperationFlags())
self.assertFalse(error)
self.assertTrue(res)
self.assertFalse(os.path.exists(f'{base_path}/other_lines2.shp'))
self.assertTrue(os.path.exists(f'{base_path}/other_lines2.txt'))
for ext in ['dbf', 'prj', 'shx']:
self.assertTrue(os.path.exists(f'{base_path}/other_lines2.{ext}'))

for ext in ['shp', 'dbf', 'prj', 'qml', 'shx']:
shutil.copy(f'{unitTestDataPath()}/lines.{ext}', f'{base_path}/ll.{ext}')
shutil.copy(f'{unitTestDataPath()}/lines.{ext}', f'{base_path}/ll.{ext}')

# file name clash
with open(f'{base_path}/yy.shp', 'wt') as f:
f.write('')
res, error = QgsFileUtils.renameDataset(f'{base_path}/ll.shp', f'{base_path}/yy.shp',
Qgis.FileOperationFlags())
self.assertFalse(res)
self.assertTrue(error)
# nothing should be renamed
for ext in ['shp', 'dbf', 'prj', 'qml', 'shx']:
self.assertTrue(os.path.exists(f'{base_path}/ll.{ext}'))

# sidecar clash
with open(f'{base_path}/yyy.shx', 'wt') as f:
f.write('')
res, error = QgsFileUtils.renameDataset(f'{base_path}/ll.shp', f'{base_path}/yyy.shp')
self.assertFalse(res)
self.assertTrue(error)
# no files should have been renamed
for ext in ['shp', 'dbf', 'prj', 'qml']:
self.assertTrue(os.path.exists(f'{base_path}/ll.{ext}'))
self.assertFalse(os.path.exists(f'{base_path}/yyy.{ext}'))
self.assertTrue(os.path.exists(f'{base_path}/ll.shx'))

# try renaming missing file
res, error = QgsFileUtils.renameDataset('/not a file.txt', f'{base_path}/not a file.txt',
Qgis.FileOperationFlags())
self.assertFalse(res)
self.assertTrue(error)


if __name__ == '__main__':
unittest.main()

0 comments on commit 1608547

Please sign in to comment.