Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Extended export plugin support #3400

Merged
merged 29 commits into from
Oct 16, 2019
Merged
Show file tree
Hide file tree
Changes from 27 commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
e5b43d4
Extended the file type export options to include not only JSON but al…
Oct 9, 2019
93e1026
Updated export.rst
austinmm Oct 10, 2019
4dfb6b9
Added examples of new format option
austinmm Oct 10, 2019
a8a480a
Updated config formats
Oct 10, 2019
8ff875b
Addec Unit test for export plugin
Oct 11, 2019
c31b488
Updated class fields to allow for easier unit testing
Oct 11, 2019
0e2c1e0
Made changes to reflect comments and suggestions made by sampsyo
Oct 12, 2019
ec705fa
Updated documents and comments to reflcet recent code changes. Cleane…
Oct 12, 2019
c5ebbe0
Added Comments to formating configurations
austinmm Oct 12, 2019
fa2c9ba
Aligned export related code with flake8 standards
Oct 13, 2019
160e4db
Merge branch 'Extended_Export_Plugin_Support' of https://github.com/a…
Oct 13, 2019
294b3cd
updated format descriptions
Oct 13, 2019
4f0a2b7
Updated Format description layout
austinmm Oct 13, 2019
db5d216
Updated tests
Oct 14, 2019
0d818ec
Ran test to ensure it works
Oct 15, 2019
5193f1b
Updated export doc
Oct 15, 2019
a9440ad
Updated test structure for export
Oct 15, 2019
07138f8
Fixed styling and test to pass cli
Oct 15, 2019
4251ff7
updated the way in which xml is outputted
Oct 15, 2019
eb6055e
Cleaned up comments and code
Oct 15, 2019
21d8091
Updated Test structure
Oct 15, 2019
623f553
Updated Test structure
Oct 15, 2019
d86e31d
Updated to reflect code changes and updated styling/format
Oct 15, 2019
7f6630c
removed xml configs from doc and code
Oct 15, 2019
d7b0e93
Updated changelog to reflect export plugin changes
Oct 15, 2019
5d7c937
fixed conflicting files issues with changelog
Oct 15, 2019
c1b646f
Merge branch 'master' into Extended_Export_Plugin_Support
austinmm Oct 15, 2019
d45b8bb
Docs fixes from my code review
sampsyo Oct 16, 2019
2291778
Docs simplifications for #3400
sampsyo Oct 16, 2019
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 76 additions & 22 deletions beetsplug/export.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -44,14 +46,27 @@ def __init__(self):
self.config.add({
'default_format': 'json',
'json': {
# json module formatting options
# JSON module formatting options.
'formatting': {
'ensure_ascii': False,
'indent': 4,
'separators': (',', ': '),
'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': []
})
Expand All @@ -78,17 +93,21 @@ def commands(self):
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
}
Expand All @@ -100,6 +119,7 @@ def run(self, lib, opts, args):
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)):
Expand All @@ -117,35 +137,69 @@ def run(self, lib, opts, args):

class ExportFormat(object):
"""The output format type"""
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, type, **kwargs):
if type == "json":
if kwargs['file_path']:
return JsonFileFormat(**kwargs)
else:
return JsonPrintFormat()
raise NotImplementedError()
def factory(cls, file_type, **kwargs):
if file_type == "json":
return JsonFormat(**kwargs)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I notice this now behaves differently from it did before—the kwargs are now forwarded unconditionally instead of only when the path is unspecified. Does this change the user-visible behavior?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should not effect the user in any way. It simply simplifies the logic in my opinion.

elif file_type == "csv":
return CSVFormat(**kwargs)
elif file_type == "xml":
return XMLFormat(**kwargs)
else:
raise NotImplementedError()

def export(self, data, **kwargs):
raise NotImplementedError()


class JsonPrintFormat(ExportFormat):
"""Outputs to the console"""
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, sys.stdout, cls=ExportEncoder, **kwargs)
json.dump(data, self.out_stream, cls=ExportEncoder, **kwargs)


class JsonFileFormat(ExportFormat):
"""Saves in a json file"""
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'):
self.path = file_path
self.mode = file_mode
self.encoding = encoding
super(XMLFormat, self).__init__(file_path, file_mode, encoding)

def export(self, data, **kwargs):
with codecs.open(self.path, self.mode, self.encoding) as f:
json.dump(data, f, cls=ExportEncoder, **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)
4 changes: 4 additions & 0 deletions docs/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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``
Expand Down
33 changes: 26 additions & 7 deletions docs/plugins/export.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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::
Expand All @@ -13,6 +15,7 @@ your library. For example, run this::

to print a JSON file containing information about your Beatles tracks.


Command-Line Options
--------------------

Expand All @@ -36,24 +39,36 @@ 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. Under the ``json``, ``csv``, and ``xml`` keys, these options are available:

- **ensure_ascii**: Escape non-ASCII characters with ``\uXXXX`` entities.
- **JSON Formatting**
- **ensure_ascii**: Escape non-ASCII characters with ``\uXXXX`` entities.

- **indent**: The number of spaces for indentation.
- **indent**: The number of spaces for indentation.

- **separators**: A ``[item_separator, dict_separator]`` tuple.
- **separators**: A ``[item_separator, dict_separator]`` tuple.

- **sort_keys**: Sorts the keys in JSON dictionaries.
- **sort_keys**: Sorts the keys in JSON dictionaries.

These options match the options from the `Python json module`_.

.. _Python json module: https://docs.python.org/2/library/json.html#basic-usage

- **CSV Formatting**
- **delimiter**: Used as the separating character between fields. The default value is a comma (,).

- **dialect**: A dialect is a construct that allows you to create, store, and re-use various formatting parameters for your data.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
- **dialect**: A dialect is a construct that allows you to create, store, and re-use various formatting parameters for your data.
- **dialect**: The kind of CSV file to produce. The default is `excel`.

Keeping this focused on what matters to users of this plugin, not to Python programmers.


These options match the options from the `Python csv module`_.

.. _Python csv module: https://docs.python.org/3/library/csv.html#csv-fmt-params

The default options look like this::

export:
Expand All @@ -62,4 +77,8 @@ The default options look like this::
ensure_ascii: False
indent: 4
separators: [',' , ': ']
sort_keys: true
sort_keys: True
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Lower case is OK here—it’s YAML, not Python.

Suggested change
sort_keys: True
sort_keys: true

csv:
formatting:
delimiter: ','
dialect: 'excel'
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
dialect: 'excel'
dialect: excel

No quotes are usually necessary in YAML.

102 changes: 102 additions & 0 deletions test/test_export.py
Original file line number Diff line number Diff line change
@@ -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')