mirror of
https://github.com/beetbox/beets.git
synced 2025-12-14 04:23:56 +01:00
Merge pull request #3400 from austinmm/Extended_Export_Plugin_Support
Extended export plugin support
This commit is contained in:
commit
a1d1265e8b
4 changed files with 213 additions and 38 deletions
|
|
@ -18,8 +18,10 @@
|
|||
from __future__ import division, absolute_import, print_function
|
||||
|
||||
import sys
|
||||
import json
|
||||
import codecs
|
||||
import json
|
||||
import csv
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
from datetime import datetime, date
|
||||
from beets.plugins import BeetsPlugin
|
||||
|
|
@ -44,7 +46,7 @@ class ExportPlugin(BeetsPlugin):
|
|||
self.config.add({
|
||||
'default_format': 'json',
|
||||
'json': {
|
||||
# json module formatting options
|
||||
# JSON module formatting options.
|
||||
'formatting': {
|
||||
'ensure_ascii': False,
|
||||
'indent': 4,
|
||||
|
|
@ -52,6 +54,19 @@ class ExportPlugin(BeetsPlugin):
|
|||
'sort_keys': True
|
||||
}
|
||||
},
|
||||
'csv': {
|
||||
# CSV module formatting options.
|
||||
'formatting': {
|
||||
# The delimiter used to seperate columns.
|
||||
'delimiter': ',',
|
||||
# The dialect to use when formating the file output.
|
||||
'dialect': 'excel'
|
||||
}
|
||||
},
|
||||
'xml': {
|
||||
# XML module formatting options.
|
||||
'formatting': {}
|
||||
}
|
||||
# TODO: Use something like the edit plugin
|
||||
# 'item_fields': []
|
||||
})
|
||||
|
|
@ -78,17 +93,21 @@ class ExportPlugin(BeetsPlugin):
|
|||
u'-o', u'--output',
|
||||
help=u'path for the output file. If not given, will print the data'
|
||||
)
|
||||
cmd.parser.add_option(
|
||||
u'-f', u'--format', default='json',
|
||||
help=u"the output format: json (default), csv, or xml"
|
||||
)
|
||||
return [cmd]
|
||||
|
||||
def run(self, lib, opts, args):
|
||||
|
||||
file_path = opts.output
|
||||
file_format = self.config['default_format'].get(str)
|
||||
file_mode = 'a' if opts.append else 'w'
|
||||
file_format = opts.format or self.config['default_format'].get(str)
|
||||
format_options = self.config[file_format]['formatting'].get(dict)
|
||||
|
||||
export_format = ExportFormat.factory(
|
||||
file_format, **{
|
||||
file_type=file_format,
|
||||
**{
|
||||
'file_path': file_path,
|
||||
'file_mode': file_mode
|
||||
}
|
||||
|
|
@ -100,6 +119,7 @@ class ExportPlugin(BeetsPlugin):
|
|||
included_keys = []
|
||||
for keys in opts.included_keys:
|
||||
included_keys.extend(keys.split(','))
|
||||
|
||||
key_filter = make_key_filter(included_keys)
|
||||
|
||||
for data_emitter in data_collector(lib, ui.decargs(args)):
|
||||
|
|
@ -117,35 +137,69 @@ class ExportPlugin(BeetsPlugin):
|
|||
|
||||
class ExportFormat(object):
|
||||
"""The output format type"""
|
||||
|
||||
@classmethod
|
||||
def factory(cls, type, **kwargs):
|
||||
if type == "json":
|
||||
if kwargs['file_path']:
|
||||
return JsonFileFormat(**kwargs)
|
||||
else:
|
||||
return JsonPrintFormat()
|
||||
raise NotImplementedError()
|
||||
|
||||
def export(self, data, **kwargs):
|
||||
raise NotImplementedError()
|
||||
|
||||
|
||||
class JsonPrintFormat(ExportFormat):
|
||||
"""Outputs to the console"""
|
||||
|
||||
def export(self, data, **kwargs):
|
||||
json.dump(data, sys.stdout, cls=ExportEncoder, **kwargs)
|
||||
|
||||
|
||||
class JsonFileFormat(ExportFormat):
|
||||
"""Saves in a json file"""
|
||||
|
||||
def __init__(self, file_path, file_mode=u'w', encoding=u'utf-8'):
|
||||
self.path = file_path
|
||||
self.mode = file_mode
|
||||
self.encoding = encoding
|
||||
# creates a file object to write/append or sets to stdout
|
||||
self.out_stream = codecs.open(self.path, self.mode, self.encoding) \
|
||||
if self.path else sys.stdout
|
||||
|
||||
@classmethod
|
||||
def factory(cls, file_type, **kwargs):
|
||||
if file_type == "json":
|
||||
return JsonFormat(**kwargs)
|
||||
elif file_type == "csv":
|
||||
return CSVFormat(**kwargs)
|
||||
elif file_type == "xml":
|
||||
return XMLFormat(**kwargs)
|
||||
else:
|
||||
raise NotImplementedError()
|
||||
|
||||
def export(self, data, **kwargs):
|
||||
with codecs.open(self.path, self.mode, self.encoding) as f:
|
||||
json.dump(data, f, cls=ExportEncoder, **kwargs)
|
||||
raise NotImplementedError()
|
||||
|
||||
|
||||
class JsonFormat(ExportFormat):
|
||||
"""Saves in a json file"""
|
||||
def __init__(self, file_path, file_mode=u'w', encoding=u'utf-8'):
|
||||
super(JsonFormat, self).__init__(file_path, file_mode, encoding)
|
||||
|
||||
def export(self, data, **kwargs):
|
||||
json.dump(data, self.out_stream, cls=ExportEncoder, **kwargs)
|
||||
|
||||
|
||||
class CSVFormat(ExportFormat):
|
||||
"""Saves in a csv file"""
|
||||
def __init__(self, file_path, file_mode=u'w', encoding=u'utf-8'):
|
||||
super(CSVFormat, self).__init__(file_path, file_mode, encoding)
|
||||
|
||||
def export(self, data, **kwargs):
|
||||
header = list(data[0].keys()) if data else []
|
||||
writer = csv.DictWriter(self.out_stream, fieldnames=header, **kwargs)
|
||||
writer.writeheader()
|
||||
writer.writerows(data)
|
||||
|
||||
|
||||
class XMLFormat(ExportFormat):
|
||||
"""Saves in a xml file"""
|
||||
def __init__(self, file_path, file_mode=u'w', encoding=u'utf-8'):
|
||||
super(XMLFormat, self).__init__(file_path, file_mode, encoding)
|
||||
|
||||
def export(self, data, **kwargs):
|
||||
# Creates the XML file structure.
|
||||
library = ET.Element(u'library')
|
||||
tracks = ET.SubElement(library, u'tracks')
|
||||
if data and isinstance(data[0], dict):
|
||||
for index, item in enumerate(data):
|
||||
track = ET.SubElement(tracks, u'track')
|
||||
for key, value in item.items():
|
||||
track_details = ET.SubElement(track, key)
|
||||
track_details.text = value
|
||||
# Depending on the version of python the encoding needs to change
|
||||
try:
|
||||
data = ET.tostring(library, encoding='unicode', **kwargs)
|
||||
except LookupError:
|
||||
data = ET.tostring(library, encoding='utf-8', **kwargs)
|
||||
|
||||
self.out_stream.write(data)
|
||||
|
|
|
|||
|
|
@ -6,6 +6,10 @@ Changelog
|
|||
|
||||
New features:
|
||||
|
||||
* :doc:`/plugins/export`: Added new ``-f`` (``--format``) flag;
|
||||
which allows for the ability to export in json, csv and xml.
|
||||
Thanks to :user:`austinmm`.
|
||||
:bug:`3402`
|
||||
* :doc:`/plugins/unimported`: lets you find untracked files in your library directory.
|
||||
* We now fetch information about `works`_ from MusicBrainz.
|
||||
MusicBrainz matches provide the fields ``work`` (the title), ``mb_workid``
|
||||
|
|
|
|||
|
|
@ -2,9 +2,11 @@ Export Plugin
|
|||
=============
|
||||
|
||||
The ``export`` plugin lets you get data from the items and export the content
|
||||
as `JSON`_.
|
||||
as `JSON`_, `CSV`_, or `XML`_.
|
||||
|
||||
.. _JSON: https://www.json.org
|
||||
.. _CSV: https://fileinfo.com/extension/csv
|
||||
.. _XML: https://fileinfo.com/extension/xml
|
||||
|
||||
Enable the ``export`` plugin (see :ref:`using-plugins` for help). Then, type ``beet export`` followed by a :doc:`query </reference/query>` to get the data from
|
||||
your library. For example, run this::
|
||||
|
|
@ -13,6 +15,7 @@ your library. For example, run this::
|
|||
|
||||
to print a JSON file containing information about your Beatles tracks.
|
||||
|
||||
|
||||
Command-Line Options
|
||||
--------------------
|
||||
|
||||
|
|
@ -36,30 +39,42 @@ The ``export`` command has these command-line options:
|
|||
|
||||
* ``--append``: Appends the data to the file instead of writing.
|
||||
|
||||
* ``--format`` or ``-f``: Specifies the format the data will be exported as. If not informed, JSON will be used by default. The format options include csv, json and xml.
|
||||
|
||||
Configuration
|
||||
-------------
|
||||
|
||||
To configure the plugin, make a ``export:`` section in your configuration
|
||||
file. Under the ``json`` key, these options are available:
|
||||
file.
|
||||
For JSON export, these options are available under the ``json`` key:
|
||||
|
||||
- **ensure_ascii**: Escape non-ASCII characters with ``\uXXXX`` entities.
|
||||
|
||||
- **indent**: The number of spaces for indentation.
|
||||
|
||||
- **separators**: A ``[item_separator, dict_separator]`` tuple.
|
||||
|
||||
- **sort_keys**: Sorts the keys in JSON dictionaries.
|
||||
|
||||
These options match the options from the `Python json module`_.
|
||||
Those options match the options from the `Python json module`_.
|
||||
Similarly, these options are available for the CSV format under the ``csv``
|
||||
key:
|
||||
|
||||
- **delimiter**: Used as the separating character between fields. The default value is a comma (,).
|
||||
- **dialect**: The kind of CSV file to produce. The default is `excel`.
|
||||
|
||||
These options match the options from the `Python csv module`_.
|
||||
|
||||
.. _Python json module: https://docs.python.org/2/library/json.html#basic-usage
|
||||
.. _Python csv module: https://docs.python.org/3/library/csv.html#csv-fmt-params
|
||||
|
||||
The default options look like this::
|
||||
|
||||
export:
|
||||
json:
|
||||
formatting:
|
||||
ensure_ascii: False
|
||||
ensure_ascii: false
|
||||
indent: 4
|
||||
separators: [',' , ': ']
|
||||
sort_keys: true
|
||||
csv:
|
||||
formatting:
|
||||
delimiter: ','
|
||||
dialect: excel
|
||||
|
|
|
|||
102
test/test_export.py
Normal file
102
test/test_export.py
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# This file is part of beets.
|
||||
# Copyright 2019, Carl Suster
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person obtaining
|
||||
# a copy of this software and associated documentation files (the
|
||||
# "Software"), to deal in the Software without restriction, including
|
||||
# without limitation the rights to use, copy, modify, merge, publish,
|
||||
# distribute, sublicense, and/or sell copies of the Software, and to
|
||||
# permit persons to whom the Software is furnished to do so, subject to
|
||||
# the following conditions:
|
||||
#
|
||||
# The above copyright notice and this permission notice shall be
|
||||
# included in all copies or substantial portions of the Software.
|
||||
|
||||
"""Test the beets.export utilities associated with the export plugin.
|
||||
"""
|
||||
|
||||
from __future__ import division, absolute_import, print_function
|
||||
|
||||
import unittest
|
||||
from test.helper import TestHelper
|
||||
import re # used to test csv format
|
||||
import json
|
||||
from xml.etree.ElementTree import Element
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
|
||||
class ExportPluginTest(unittest.TestCase, TestHelper):
|
||||
def setUp(self):
|
||||
self.setup_beets()
|
||||
self.load_plugins('export')
|
||||
self.test_values = {'title': 'xtitle', 'album': 'xalbum'}
|
||||
|
||||
def tearDown(self):
|
||||
self.unload_plugins()
|
||||
self.teardown_beets()
|
||||
|
||||
def execute_command(self, format_type, artist):
|
||||
query = ','.join(self.test_values.keys())
|
||||
out = self.run_with_output(
|
||||
'export',
|
||||
'-f', format_type,
|
||||
'-i', query,
|
||||
artist
|
||||
)
|
||||
return out
|
||||
|
||||
def create_item(self):
|
||||
item, = self.add_item_fixtures()
|
||||
item.artist = 'xartist'
|
||||
item.title = self.test_values['title']
|
||||
item.album = self.test_values['album']
|
||||
item.write()
|
||||
item.store()
|
||||
return item
|
||||
|
||||
def test_json_output(self):
|
||||
item1 = self.create_item()
|
||||
out = self.execute_command(
|
||||
format_type='json',
|
||||
artist=item1.artist
|
||||
)
|
||||
json_data = json.loads(out)[0]
|
||||
for key, val in self.test_values.items():
|
||||
self.assertTrue(key in json_data)
|
||||
self.assertEqual(val, json_data[key])
|
||||
|
||||
def test_csv_output(self):
|
||||
item1 = self.create_item()
|
||||
out = self.execute_command(
|
||||
format_type='csv',
|
||||
artist=item1.artist
|
||||
)
|
||||
csv_list = re.split('\r', re.sub('\n', '', out))
|
||||
head = re.split(',', csv_list[0])
|
||||
vals = re.split(',|\r', csv_list[1])
|
||||
for index, column in enumerate(head):
|
||||
self.assertTrue(self.test_values.get(column, None) is not None)
|
||||
self.assertEqual(vals[index], self.test_values[column])
|
||||
|
||||
def test_xml_output(self):
|
||||
item1 = self.create_item()
|
||||
out = self.execute_command(
|
||||
format_type='xml',
|
||||
artist=item1.artist
|
||||
)
|
||||
library = ET.fromstring(out)
|
||||
self.assertIsInstance(library, Element)
|
||||
for track in library[0]:
|
||||
for details in track:
|
||||
tag = details.tag
|
||||
txt = details.text
|
||||
self.assertTrue(tag in self.test_values, msg=tag)
|
||||
self.assertEqual(self.test_values[tag], txt, msg=txt)
|
||||
|
||||
|
||||
def suite():
|
||||
return unittest.TestLoader().loadTestsFromName(__name__)
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main(defaultTest='suite')
|
||||
Loading…
Reference in a new issue