Вы уже знакомы с библиотекой pandas и ее базовой функциональностью по анализу данных. Также знаете, что в ее основе лежат два типа данных: Dataframe
и Series
. На их основе выполняется большая часть взаимодействия с данными, вычислений и анализа.
В этом материале вы познакомитесь с инструментами, предназначенными для чтения данных, сохраненных в разных источниках (файлах и базах данных). Также научитесь записывать структуры в эти форматы, не задумываясь об используемых технологиях.
Этот раздел посвящен функциям API I/O (ввода/вывода), которые pandas предоставляет для чтения и записи данных прямо в виде объектов Dataframe
. Начнем с текстовых файлов, а затем перейдем к более сложным бинарным форматам.
А в конце узнаем, как взаимодействовать с распространенными базами данных, такими как SQL
и NoSQL
, используя для этого реальные примеры. Разберем, как считывать данные из базы данных, сохраняя их в виде Dataframe
.
Инструменты API I/O
pandas — библиотека, предназначенная для анализа данных, поэтому логично предположить, что она в первую очередь используется для вычислений и обработки данных. Процесс записи и чтения данных на/с внешние файлы — это часть обработки. Даже на этом этапе можно выполнять определенные операции, готовя данные к взаимодействию.
Первый шаг очень важен, поэтому для него представлен полноценный инструмент в библиотеке, называемый API I/O. Функции из него можно разделить на две категории: для чтения и для записи.
Чтение | Запись |
---|---|
read_csv | to_csv |
read_excel | to_excel |
read_hdf | to_hdf |
read_sql | to_sql |
read_json | to_json |
read_html | to_html |
read_stata | to_stata |
read_clipboard | to_clipboard |
read_pickle | to_pickle |
read_msgpack | to_msgpack (экспериментальный) |
read_gbq | to_gbq (экспериментальный) |
CSV и текстовые файлы
Все привыкли к записи и чтению файлов в текстовой форме. Чаще всего они представлены в табличной форме. Если значения в колонке разделены запятыми, то это формат CSV (значения, разделенные запятыми), который является, наверное, самым известным форматом.
Другие формы табличных данных могут использовать в качестве разделителей пробелы или отступы. Они хранятся в текстовых файлах разных типов (обычно с расширением .txt).
Такой тип файлов — самый распространенный источник данных, который легко расшифровывать и интерпретировать. Для этого pandas предлагает набор функций:
read_csv
read_table
to_csv
Чтение данных из CSV или текстовых файлов
Самая распространенная операция по взаимодействию с данными при анализе данных — чтение их из файла CSV или как минимум текстового файла.
Для этого сперва нужно импортировать отдельные библиотеки.
>>> import numpy as np
>>> import pandas as pd
Чтобы сначала увидеть, как pandas работает с этими данными, создадим маленький файл CSV в рабочем каталоге, как показано на следующем изображении и сохраним его как ch05_01.csv
.
white,red,blue,green,animal
1,5,2,3,cat
2,7,8,5,dog
3,3,6,7,horse
2,2,8,3,duck
4,4,2,1,mouse
Поскольку разделителем в файле выступают запятые, можно использовать функцию read_csv()
для чтения его содержимого и добавления в объект Dataframe
.
>>> csvframe = pd.read_csv('ch05_01.csv')
>>> csvframe
white | red | blue | green | animal | |
---|---|---|---|---|---|
0 | 1 | 5 | 2 | 3 | cat |
1 | 2 | 7 | 8 | 5 | dog |
2 | 3 | 3 | 6 | 7 | horse |
3 | 2 | 2 | 8 | 3 | duck |
4 | 4 | 4 | 2 | 1 | mouse |
Это простая операция. Файлы CSV — это табличные данные, где значения одной колонки разделены запятыми. Поскольку это все еще текстовые файлы, то подойдет и функция read_table()
, но в таком случае нужно явно указывать разделитель.
>>> pd.read_table('ch05_01.csv',sep=',')
white | red | blue | green | animal | |
---|---|---|---|---|---|
0 | 1 | 5 | 2 | 3 | cat |
1 | 2 | 7 | 8 | 5 | dog |
2 | 3 | 3 | 6 | 7 | horse |
3 | 2 | 2 | 8 | 3 | duck |
4 | 4 | 4 | 2 | 1 | mouse |
В этом примере все заголовки, обозначающие названия колонок, определены в первой строчке. Но это не всегда работает именно так. Иногда сами данные начинаются с первой строки.
Создадим файл ch05_02.csv
1,5,2,3,cat
2,7,8,5,dog
3,3,6,7,horse
2,2,8,3,duck
4,4,2,1,mouse
>>> pd.read_csv('ch05_02.csv')
1 | 5 | 2 | 3 | cat | |
---|---|---|---|---|---|
0 | 2 | 7 | 8 | 5 | dog |
1 | 3 | 3 | 6 | 7 | horse |
2 | 2 | 2 | 8 | 3 | duck |
3 | 4 | 4 | 2 | 1 | mouse |
4 | 4 | 4 | 2 | 1 | mouse |
В таком случае нужно убедиться, что pandas не присвоит названиям колонок значения первой строки, передав None
параметру header
.
>>> pd.read_csv('ch05_02.csv', header=None)
0 | 1 | 2 | 3 | 4 | |
---|---|---|---|---|---|
0 | 1 | 5 | 2 | 3 | cat |
1 | 2 | 7 | 8 | 5 | dog |
2 | 3 | 3 | 6 | 7 | horse |
3 | 2 | 2 | 8 | 3 | duck |
4 | 4 | 4 | 2 | 1 | mouse |
Также можно самостоятельно определить названия, присвоив список меток параметру names
.
>>> pd.read_csv('ch05_02.csv', names=['white','red','blue','green','animal'])
white | red | blue | green | animal | |
---|---|---|---|---|---|
0 | 1 | 5 | 2 | 3 | cat |
1 | 2 | 7 | 8 | 5 | dog |
2 | 3 | 3 | 6 | 7 | horse |
3 | 2 | 2 | 8 | 3 | duck |
4 | 4 | 4 | 2 | 1 | mouse |
В более сложных случаях когда нужно создать Dataframe
с иерархической структурой на основе данных из файла CSV, можно расширить возможности функции read_csv()
добавив параметр index_col
, который конвертирует колонки в значения индексов.
Чтобы лучше разобраться с этой особенностью, создайте новый CSV-файл с двумя колонками, которые будут индексами в иерархии. Затем сохраните его в рабочую директорию под именем ch05_03.csv
.
Создадим файл ch05_03.csv
color,status,item1,item2,item3
black,up,3,4,6
black,down,2,6,7
white,up,5,5,5
white,down,3,3,2
white,left,1,2,1
red,up,2,2,2
red,down,1,1,4
>>> pd.read_csv('ch05_03.csv', index_col=['color','status'])
item1 | item2 | item3 | ||
---|---|---|---|---|
color | status | |||
black | up | 3 | 4 | 6 |
down | 2 | 6 | 7 | |
white | up | 5 | 5 | 5 |
down | 3 | 3 | 2 | |
left | 1 | 2 | 1 | |
red | up | 2 | 2 | 2 |
down | 1 | 1 | 4 |
Использованием RegExp для парсинга файлов TXT
Иногда бывает так, что в файлах, из которых нужно получить данные, нет разделителей, таких как запятая или двоеточие. В таких случаях на помощь приходят регулярные выражения. Задать такое выражение можно в функции read_table()
с помощью параметра sep
.
Чтобы лучше понимать regexp и то, как их использовать для разделения данных, начнем с простого примера. Например, предположим, что файл TXT
имеет значения, разделенные пробелами и отступами хаотично. В таком случае regexp подойдут идеально, ведь они позволяют учитывать оба вида разделителей. Подстановочный символ /s*
отвечает за все символы пробелов и отступов (если нужны только отступы, то используется /t
), а *
указывает на то, что символов может быть несколько. Таким образом значения могут быть разделены большим количеством пробелов.
. | Любой символ за исключением новой строки |
\d | Цифра |
\D | Не-цифровое значение |
\s | Пробел |
\S | Не-пробельное значение |
\n | Новая строка |
\t | Отступ |
\uxxxx | Символ Unicode в шестнадцатеричном виде |
Возьмем в качестве примера случай, где значения разделены отступами или пробелами в хаотическом порядке.
Создадим файл ch05_04.txt
white red blue green
1 5 2 3
2 7 8 5
3 3 6 7
>>> pd.read_table('ch05_04.txt',sep='\s+', engine='python')
white | red | blue | green | |
---|---|---|---|---|
0 | 1 | 5 | 2 | 3 |
1 | 2 | 7 | 8 | 5 |
2 | 3 | 3 | 6 | 7 |
Результатом будет идеальный Dataframe
, в котором все значения корректно отсортированы.
Дальше будет пример, который может показаться странным, но на практике он встречается не так уж и редко. Он пригодится для понимания принципов работы regexp. На самом деле, о разделителях (запятых, пробелах, отступах и так далее) часто думают как о специальных символах, но иногда ими выступают и буквенно-цифровые символы, например, целые числа.
В следующем примере необходимо извлечь цифровую часть из файла TXT
, в котором последовательность символов перемешана с буквами.
Не забудьте задать параметр None
для параметра header
, если в файле нет заголовков колонок.
Создадим файл ch05_05.txt
000END123AAA122
001END124BBB321
002END125CCC333
>>> pd.read_table('ch05_05.txt', sep='\D+', header=None, engine='python')
0 | 1 | 2 | |
---|---|---|---|
0 | 0 | 123 | 122 |
1 | 1 | 124 | 321 |
2 | 2 | 125 | 333 |
Еще один распространенный пример — удаление из данных отдельных строк при извлечении. Так, не всегда нужны заголовки или комментарии. Благодаря параметру skiprows
можно исключить любые строки, просто присвоим ему массив с номерами строк, которые не нужно парсить.
Обратите внимание на способ использования параметра. Если нужно исключить первые пять строк, то необходимо писать skiprows = 5
, но для удаления только пятой строки — [5]
.
Создадим файл ch05_06.txt
########### LOG FILE ############
This file has been generated by automatic system
white,red,blue,green,animal
12-Feb-2015: Counting of animals inside the house
1,5,2,3,cat
2,7,8,5,dog
13-Feb-2015: Counting of animals outside the house
3,3,6,7,horse
2,2,8,3,duck
4,4,2,1,mouse
>>> pd.read_table('ch05_06.txt',sep=',',skiprows=[0,1,3,6])
white | red | blue | green | animal | |
---|---|---|---|---|---|
0 | 1 | 5 | 2 | 3 | cat |
1 | 2 | 7 | 8 | 5 | dog |
2 | 3 | 3 | 6 | 7 | horse |
3 | 2 | 2 | 8 | 3 | duck |
4 | 4 | 4 | 2 | 1 | mouse |
Чтение файлов TXT с разделением на части
При обработке крупных файлов или необходимости использовать только отдельные их части часто требуется считывать их кусками. Это может пригодится, если необходимо воспользоваться перебором или же целый файл не нужен.
Если требуется получить лишь часть файла, можно явно указать количество требуемых строк. Благодаря параметрам nrows
и skiprows
можно выбрать стартовую строку n
(n = SkipRows
) и количество строк, которые нужно считать после (nrows = 1
).
>>> pd.read_csv('ch05_02.csv',skiprows=[2],nrows=3,header=None)
0 | 1 | 2 | 3 | 4 | |
---|---|---|---|---|---|
0 | 1 | 5 | 2 | 3 | cat |
1 | 2 | 7 | 8 | 5 | dog |
2 | 2 | 2 | 8 | 3 | duck |
Еще одна интересная и распространенная операция — разбитие на части того куска текста, который требуется парсить. Затем для каждой части может быть выполнена конкретная операция для получения перебора части за частью.
Например, нужно суммировать значения в колонке каждой третьей строки и затем вставить результат в объект Series
. Это простой и непрактичный пример, но с его помощью легко разобраться, а поняв механизм работы, его проще будет применять в сложных ситуациях.
>>> out = pd.Series()
>>> i = 0
>>> pieces = pd.read_csv('ch05_01.csv',chunksize=3)
>>> for piece in pieces:
... out.set_value(i,piece['white'].sum())
... i = i + 1
...
>>> out
0 6
1 6
dtype: int64
Запись данных в CSV
В дополнение к чтению данных из файла, распространенной операцией является запись в файл данных, полученных, например, в результате вычислений или просто из структуры данных.
Например, нужно записать данные из объекта Dataframe
в файл CSV. Для этого используется функция to_csv()
, принимающая в качестве аргумента имя файла, который будет сгенерирован.
>>> frame = pd.DataFrame(np.arange(16).reshape((4,4)),
index = ['red', 'blue', 'yellow', 'white'],
columns = ['ball', 'pen', 'pencil', 'paper'])
>>> frame.to_csv('ch05_07.csv')
Если открыть новый файл ch05_07.csv
, сгенерированный библиотекой pandas, то он будет напоминать следующее:
,ball,pen,pencil,paper
0,1,2,3
4,5,6,7
8,9,10,11
12,13,14,15
На предыдущем примере видно, что при записи Dataframe
в файл индексы и колонки отмечаются в файле по умолчанию. Это поведение можно изменить с помощью параметров index
и header
. Им нужно передать значение False
.
>>> frame.to_csv('ch05_07b.csv', index=False, header=False)
Файл ch05_07b.csv
1,2,3
5,6,7
9,10,11
13,14,15
Важно запомнить, что при записи файлов значения NaN
из структуры данных представлены в виде пустых полей в файле.
>>> frame3 = pd.DataFrame([[6,np.nan,np.nan,6,np.nan],
... [np.nan,np.nan,np.nan,np.nan,np.nan],
... [np.nan,np.nan,np.nan,np.nan,np.nan],
... [20,np.nan,np.nan,20.0,np.nan],
... [19,np.nan,np.nan,19.0,np.nan]
... ],
... index=['blue','green','red','white','yellow'],
... columns=['ball','mug','paper','pen','pencil'])
>>> frame3
Unnamed: 0 | ball | mug | paper | pen | pencil | |
---|---|---|---|---|---|---|
0 | blue | 6.0 | NaN | NaN | 6.0 | NaN |
1 | green | NaN | NaN | NaN | NaN | NaN |
2 | red | NaN | NaN | NaN | NaN | NaN |
3 | white | 20.0 | NaN | NaN | 20.0 | NaN |
4 | yellow | 19.0 | NaN | NaN | 19.0 | NaN |
>>> frame3.to_csv('ch05_08.csv')
,ball,mug,paper,pen,pencil
blue,6.0,,,6.0,
green,,,,,
red,,,,,
white,20.0,,,20.0,
yellow,19.0,,,19.0,
Но их можно заменить на любое значение, воспользовавшись параметром na_rep
из функции to_csv
. Это может быть NULL
, 0
или то же NaN
.
>>> frame3.to_csv('ch05_09.csv', na_rep ='NaN')
,ball,mug,paper,pen,pencil
blue,6.0,NaN,NaN,6.0,NaN
green,NaN,NaN,NaN,NaN,NaN
red,NaN,NaN,NaN,NaN,NaN
white,20.0,NaN,NaN,20.0,NaN
yellow,19.0,NaN,NaN,19.0,NaN
Примечание: в предыдущих примерах использовались только объекты
Dataframe
, но все функции применимы и по отношению кSeries
.
Чтение и запись файлов HTML
pandas предоставляет соответствующую пару функций API I/O для формата HTML.
read_html()
to_html()
Эти две функции очень полезны. С их помощью можно просто конвертировать сложные структуры данных, такие как Dataframe
, прямо в таблицы HTML
, не углубляясь в синтаксис.
Обратная операция тоже очень полезна, потому что сегодня веб является одним из основных источников информации. При этом большая часть информации не является «готовой к использованию», будучи упакованной в форматы TXT
или CSV
. Необходимые данные чаще всего представлены лишь на части страницы. Так что функция для чтения окажется полезной очень часто.
Такая деятельность называется парсингом (веб-скрапингом). Этот процесс становится фундаментальным элементом первого этапа анализа данных: поиска и подготовки.
Примечание: многие сайты используют
HTML5
для предотвращения ошибок недостающих модулей или сообщений об ошибках. Настоятельно рекомендуется использовать модульhtml5lib
в Anaconda.
conda install html5lib
Запись данных в HTML
При записи Dataframe
в HTML-таблицу внутренняя структура объекта автоматически конвертируется в сетку вложенных тегов <th>
, <tr>
и <td>
, сохраняя иерархию. Для этой функции даже не нужно знать HTML.
Поскольку структуры данных, такие как Dataframe
, могут быть большими и сложными, это очень удобно иметь функцию, которая сама создает таблицу на странице. Вот пример.
Сначала создадим простейший Dataframe
. Дальше с помощью функции to_html()
прямо конвертируем его в таблицу HTML.
>>> frame = pd.DataFrame(np.arange(4).reshape(2,2))
Поскольку функции API I/O определены в структуре данных pandas, вызывать to_html()
можно прямо к экземпляру Dataframe
.
>>> print(frame.to_html())
<table border="1" class="dataframe">
<thead>
<tr style="text-align: right;">
<th></th>
<th>0</th>
<th>1</th>
</tr>
</thead>
<tbody>
<tr>
<th>0</th>
<td>0</td>
<td>1</td>
</tr>
<tr>
<th>1</th>
<td>2</td>
<td>3</td>
</tr>
</tbody>
</table>
Результат — готовая таблица HTML, сохранившая всю внутреннюю структуру.
В следующем примере вы увидите, как таблицы автоматически появляются в файле HTML. В этот раз сделаем объект более сложным, добавив в него метки индексов и названия колонок.
>>> frame = pd.DataFrame(np.random.random((4,4)),
... index = ['white','black','red','blue'],
... columns = ['up','down','right','left'])
>>> frame
up | down | right | left | |
---|---|---|---|---|
white | 0.420378 | 0.533364 | 0.758968 | 0.132560 |
black | 0.711775 | 0.375598 | 0.936847 | 0.495377 |
red | 0.630547 | 0.998588 | 0.592496 | 0.076336 |
blue | 0.308752 | 0.158057 | 0.647739 | 0.907514 |
Теперь попробуем написать страницу HTML с помощью генерации строк. Это простой пример, но он позволит разобраться с функциональностью pandas прямо в браузере.
Сначала создадим строку, которая содержит код HTML-страницы.
>>> s = ['<HTML>']
>>> s.append('<HEAD><TITLE>My DataFrame</TITLE></HEAD>')
>>> s.append('<BODY>')
>>> s.append(frame.to_html())
>>> s.append('</BODY></HTML>')
>>> html = ''.join(s)
Теперь когда метка html
содержит всю необходимую разметку, можно писать прямо в файл myFrame.html
:
>>> html_file = open('myFrame.html','w')
>>> html_file.write(html)
>>> html_file.close()
В рабочей директории появится новый файл, myFrame.html
. Двойным кликом его можно открыть прямо в браузере. В левом верхнем углу будет следующая таблица:
Чтение данных из HTML-файла
pandas может с легкостью генерировать HTML-таблицы на основе данных Dataframe
. Обратный процесс тоже возможен. Функция read_html()
осуществляет парсинг HTML и ищет таблицу. В случае успеха она конвертирует ее в Dataframe
, который можно использовать в процессе анализа данных.
Если точнее, то read_html()
возвращает список объектов Dataframe, даже если таблица одна. Источник может быть разных типов. Например, может потребоваться прочитать HTML-файл в любой папке. Или попробовать парсить HTML из прошлого примера:
>>> web_frames = pd.read_html('myFrame.html')
>>> web_frames[0]
Unnamed: 0 | up | down | right | left | |
---|---|---|---|---|---|
0 | white | 0.420378 | 0.533364 | 0.758968 | 0.132560 |
1 | black | 0.711775 | 0.375598 | 0.936847 | 0.495377 |
2 | red | 0.630547 | 0.998588 | 0.592496 | 0.076336 |
3 | blue | 0.308752 | 0.158057 | 0.647739 | 0.907514 |
Все теги, отвечающие за формирование таблицы в HTML в финальном объекте не представлены. web_frames
— это список Dataframe
, хотя в этом случае объект был всего один. К нему можно обратиться стандартным путем. Здесь достаточно лишь указать на него через индекс 0.
Но самый распространенный режим работы функции read_html()
— прямой парсинг ссылки. Таким образом страницы парсятся прямо, а из них извлекаются таблицы.
Например, дальше будет вызвана страница, на которой есть HTML-таблица, показывающая рейтинг с именами и баллами.
>>> ranking = pd.read_html('https://www.meccanismocomplesso.org/en/
meccanismo-complesso-sito-2/classifica-punteggio/')
>>> ranking[0]
# | Nome | Exp | Livelli | right |
---|---|---|---|---|
0 | 1 | Fabio Nelli | 17521 | NaN |
1 | 2 | admin | 9029 | NaN |
2 | 3 | BrunoOrsini | 2124 | NaN |
… | … | … | … | … |
247 | 248 | emilibassi | 1 | NaN |
248 | 249 | mehrbano | 1 | NaN |
249 | 250 | NIKITA PANCHAL | 1 | NaN |
Чтение данных из XML
В списке функции API I/O нет конкретного инструмента для работы с форматом XML (Extensible Markup Language). Тем не менее он очень важный, поскольку многие структурированные данные представлены именно в нем. Но это и не проблема, ведь в Python есть много других библиотек (помимо pandas), которые подходят для чтения и записи данных в формате XML.
Одна их них называется lxml
и она обеспечивает идеальную производительность при парсинге даже самых крупных файлов. Этот раздел будет посвящен ее использованию, интеграции с pandas и способам получения Dataframe
с нужными данными. Больше подробностей о lxml
есть на официальном сайте http://lxml.de/index.html.
Возьмем в качестве примера следующий файл. Сохраните его в рабочей директории с названием books.xml
.
<?xml version="1.0"?>
<Catalog>
<Book id="ISBN9872122367564">
<Author>Ross, Mark</Author>
<Title>XML Cookbook</Title>
<Genre>Computer</Genre>
<Price>23.56</Price>
<PublishDate>2014-22-01</PublishDate>
</Book>
<Book id="ISBN9872122367564">
<Author>Bracket, Barbara</Author>
<Title>XML for Dummies</Title>
<Genre>Computer</Genre>
<Price>35.95</Price>
<PublishDate>2014-12-16</PublishDate>
</Book>
</Catalog>
В этом примере структура файла будет конвертирована и преподнесена в виде Dataframe
. В первую очередь нужно импортировать субмодуль objectify
из библиотеки.
>>> from lxml import objectify
Теперь нужно всего лишь использовать его функцию parse()
.
>>> xml = objectify.parse('books.xml')
>>> xml
<lxml.etree._ElementTree object at 0x0000000009734E08>
Результатом будет объект tree, который является внутренней структурой данных модуля lxml
.
Чтобы познакомиться с деталями этого типа, пройтись по его структуре или выбирать элемент за элементом, в первую очередь нужно определить корень. Для этого используется функция getroot()
.
>>> root = xml.getroot()
Теперь можно получать доступ к разным узлам, каждый из которых соответствует тегам в оригинальном XML-файле. Их имена также будут соответствовать. Для выбора узлов нужно просто писать отдельные теги через точки, используя иерархию дерева.
>>> root.Book.Author
'Ross, Mark'
>>> root.Book.PublishDate
'2014-22-01'
В такой способ доступ к узлам можно получить индивидуально. А getchildren()
обеспечит доступ ко всем дочерним элементами.
>>> root.getchildren()
[<Element Book at 0x9c66688>, <Element Book at 0x9c66e08>]
При использовании атрибута tag
вы получаете название соответствующего тега из родительского узла.
>>> [child.tag for child in root.Book.getchildren()]
['Author', 'Title', 'Genre', 'Price', 'PublishDate']
А text
покажет значения в этих тегах.
>>> [child.text for child in root.Book.getchildren()]
['Ross, Mark', 'XML Cookbook', 'Computer', '23.56', '2014-22-01']
Но вне зависимости от возможности двигаться по структуре lxml.etree
, ее нужно конвертировать в Dataframe
. Воспользуйтесь следующей функцией, которая анализирует содержимое eTree и заполняет им Dataframe
строчка за строчкой.
>>> def etree2df(root):
... column_names = []
... for i in range(0, len(root.getchildren()[0].getchildren())):
... column_names.append(root.getchildren()[0].getchildren()[i].tag)
... xmlframe = pd.DataFrame(columns=column_names)
... for j in range(0, len(root.getchildren())):
... obj = root.getchildren()[j].getchildren()
... texts = []
... for k in range(0, len(column_names)):
... texts.append(obj[k].text)
... row = dict(zip(column_names, texts))
... row_s = pd.Series(row)
... row_s.name = j
... xmlframe = xmlframe.append(row_s)
... return xmlframe
>>> etree2df(root)
Author | Title | Genre | Price | PublishDate | |
---|---|---|---|---|---|
0 | Ross, Mark | XML Cookbook | Computer | 23.56 | 2014-01-22 |
1 | Bracket, Barbara | XML for Dummies | Computer | 35.95 | 2014-12-16 |