From 7ffcc506f7c98bcdd91392520160cdfab2253842 Mon Sep 17 00:00:00 2001 From: Patricio Paez Date: Sat, 28 Feb 2015 10:43:43 -0600 Subject: [PATCH 001/167] =?UTF-8?q?Empezar=20la=20referencia=20de=20los=20?= =?UTF-8?q?m=C3=B3dulos?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Habilitar la extensión autodoc - Crear la sección Referencia --- docs/conf.py | 4 ++-- docs/index.rst | 1 + docs/reference.rst | 27 +++++++++++++++++++++++++++ 3 files changed, 30 insertions(+), 2 deletions(-) create mode 100644 docs/reference.rst diff --git a/docs/conf.py b/docs/conf.py index 685c8b7..839bcb7 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -19,7 +19,7 @@ # If extensions (or modules to document with autodoc) are in another directory, # add these directories to sys.path here. If the directory is relative to the # documentation root, use os.path.abspath to make it absolute, like shown here. -#sys.path.insert(0, os.path.abspath('.')) +sys.path.insert(0, os.path.abspath('..')) # -- General configuration ------------------------------------------------ @@ -29,7 +29,7 @@ # Add any Sphinx extension module names here, as strings. They can be # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # ones. -extensions = [] +extensions = ['sphinx.ext.autodoc'] # Add any paths that contain templates here, relative to this directory. templates_path = ['_templates'] diff --git a/docs/index.rst b/docs/index.rst index e7f8d95..cec7834 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -14,6 +14,7 @@ Contents: intro uso devel + reference glosario diff --git a/docs/reference.rst b/docs/reference.rst new file mode 100644 index 0000000..08c1c52 --- /dev/null +++ b/docs/reference.rst @@ -0,0 +1,27 @@ +========== +Referencia +========== + +admincfdi +--------- + +.. automodule:: admincfdi + :members: + :undoc-members: + :private-members: + +pyutil +------ + +.. automodule:: pyutil + :members: + :undoc-members: + :private-members: + +values +------ + +.. automodule:: values + :members: + :undoc-members: + :private-members: From 3bae3fb2a1ef19ba4c87bda506cd2ec21d6c80cc Mon Sep 17 00:00:00 2001 From: Patricio Paez Date: Sat, 28 Feb 2015 11:01:33 -0600 Subject: [PATCH 002/167] Explicar la descarga de facturas del SAT MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Qué código está involucrado - Los pasos generales --- docs/devel.rst | 87 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 87 insertions(+) diff --git a/docs/devel.rst b/docs/devel.rst index d01bdf1..c8dc4d3 100644 --- a/docs/devel.rst +++ b/docs/devel.rst @@ -1,3 +1,90 @@ ========== Desarrollo ========== + +Descarga de facturas del SAT +============================ + +La descarga de los archivos XML del sitio web del SAT se +maneja en la primera pestaña de la interfase gráfica. + +Primeramente el usuario debe llenar +datos y/o seleccionar opciones en estos tres apartados: + +- Datos de acceso +- Tipo de consulta +- Opciones de búsqueda + +El proceso de la descarga se inicia mediante el botón +``Descargar``, el cual está ligado al método +:func:`admincfdi.Application.button_download_sat_click` +de la aplicación, que ejecuta +estos dos métodos: + +- :func:`admincfdi.Application._validate_download_sat` + +- :func:`admincfdi.Application._download_sat` + +El proceso de descarga consiste en estos pasos: + +#. Lanzar el navegador + +#. Entrar a la página de búsquedas de CFDIs + + - Navegar a la página de login de CFDIs + + - Llenar el usuario y la contraseña (RFC y CIEC) + + - Enviar los datos al servidor + + - Navegar a la página de búsqueda de facturas emitidas, + o a la de facturas recibidas + +#. Solicitar la búsqueda + + - Seleccionar el tipo de búsqueda + - Llenar los datos de la búsqueda + - Enviar los datos al servidor + +#. Descargar cada renglón de los resultados + + - Encontrar los elementos con atributo ``name`` + igual a *download*, corresponden al ícono + de descarga a la izquierda en cada renglón. + + - Iterar en cada elemento de esta lista: + + - Concatenar la URL base + de CFDIs con el valor del atributo ``onclick`` + del elemento + - Hacer la solicitud GET a esta URL + +#. Cerrar la sesión +#. Cerrar el navegador + +En caso de alguna falla en los primeros cuatro pasos, +se intenta el 5, y por último y en todos los casos +se realiza el paso 6. + +El avance del proceso se indica al usuario mediante +textos cortos que se muestran en una línea de estado +de la interfase gráfica, en esta secuencia:: + + Abriendo Firefox... + Conectando... + Conectado... + Buscando... + Factura 1 de 12 + Factura 2 de 12 + Factura 3 de 12 + Factura 4 de 12 + Factura 5 de 12 + Factura 6 de 12 + Factura 7 de 12 + Factura 8 de 12 + Factura 9 de 12 + Factura 10 de 12 + Factura 11 de 12 + Factura 12 de 12 + Desconectando... + Desconectado... From 977d3bd52bd09ca3616fd443fbd6ba712bda301d Mon Sep 17 00:00:00 2001 From: Patricio Paez Date: Sat, 28 Feb 2015 12:44:24 -0600 Subject: [PATCH 003/167] =?UTF-8?q?Explicar=20los=20principales=20m=C3=B3d?= =?UTF-8?q?ulos=20de=20admin-cfdi?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Por ahora solo los que tienen que ver con la descarga de CFDIs --- docs/devel.rst | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/docs/devel.rst b/docs/devel.rst index c8dc4d3..ce803f2 100644 --- a/docs/devel.rst +++ b/docs/devel.rst @@ -2,6 +2,24 @@ Desarrollo ========== +Estructura +========== + +La aplicación consta de los siguientes archivos: + +- admincfdi.py Implementa la interfase gráfica y + es la aplicación principal. + +- values.py Tiene la clase Global que centraliza + valores que se usan en los otros módulos. Por + ejemplo, las URLs y valores id de la página web + de CFDIs del SAT están en el atributo SAT, + es un diccionario que es usado + en la descarga de CFDIs. + +- pyutil.py Tiene varias clases que implementan + utilerías usadas por los otros módulos. + Descarga de facturas del SAT ============================ From 56e6b082f98aa3944776c8d195481c942ffd4a0b Mon Sep 17 00:00:00 2001 From: Patricio Paez Date: Sat, 28 Feb 2015 18:46:15 -0600 Subject: [PATCH 004/167] Crear DescargaSAT MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Es copia sin refactorizar de _download_sat() - Queda en pyutil.py como las demás herramientas --- pyutil.py | 215 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 215 insertions(+) diff --git a/pyutil.py b/pyutil.py index f460fac..eadd60a 100644 --- a/pyutil.py +++ b/pyutil.py @@ -36,6 +36,7 @@ from tkinter.filedialog import askopenfilename from tkinter import messagebox from pysimplesoap.client import SoapClient, SoapFault +from selenium import webdriver try: from subprocess import DEVNULL except ImportError: @@ -1649,3 +1650,217 @@ def _format_date(self, date_string): return date.strftime('{}, %d de {} de %Y'.format( d[date.weekday()], m[date.month])) + +class DescargaSAT(object): + + def __init__(this, data, app): + self = app + self._set('msg_user', 'Abriendo Firefox...', True) + page_query = self.g.SAT['page_receptor'] + if data['type_invoice'] == 1: + page_query = self.g.SAT['page_emisor'] + # To prevent download dialog + profile = webdriver.FirefoxProfile() + profile.set_preference( + 'browser.download.folderList', 2) + profile.set_preference( + 'browser.download.manager.showWhenStarting', False) + profile.set_preference( + 'browser.helperApps.alwaysAsk.force', False) + profile.set_preference( + 'browser.helperApps.neverAsk.saveToDisk', + 'text/xml, application/octet-stream, application/xml') + profile.set_preference( + 'browser.download.dir', data['user_sat']['target_sat']) + # mrE - desactivar telemetry + profile.set_preference( + 'toolkit.telemetry.prompted', 2) + profile.set_preference( + 'toolkit.telemetry.rejected', True) + profile.set_preference( + 'toolkit.telemetry.enabled', False) + profile.set_preference( + 'datareporting.healthreport.service.enabled', False) + profile.set_preference( + 'datareporting.healthreport.uploadEnabled', False) + profile.set_preference( + 'datareporting.healthreport.service.firstRun', False) + profile.set_preference( + 'datareporting.healthreport.logging.consoleEnabled', False) + profile.set_preference( + 'datareporting.policy.dataSubmissionEnabled', False) + profile.set_preference( + 'datareporting.policy.dataSubmissionPolicyResponseType', 'accepted-info-bar-dismissed') + #profile.set_preference( + # 'datareporting.policy.dataSubmissionPolicyAccepted'; False) # este me marca error, why? + #oculta la gran flecha animada al descargar + profile.set_preference( + 'browser.download.animateNotifications', False) + try: + browser = webdriver.Firefox(profile) + self._set('msg_user', 'Conectando...', True) + browser.get(self.g.SAT['page_init']) + txt = browser.find_element_by_name(self.g.SAT['user']) + txt.send_keys(data['user_sat']['user_sat']) + txt = browser.find_element_by_name(self.g.SAT['password']) + txt.send_keys(data['user_sat']['password']) + txt.submit() + self.util.sleep(3) + self._set('msg_user', 'Conectado...', True) + browser.get(page_query) + self.util.sleep(3) + self._set('msg_user', 'Buscando...', True) + if data['type_search'] == 1: + txt = browser.find_element_by_id(self.g.SAT['uuid']) + txt.click() + txt.send_keys(data['search_uuid']) + else: + # Descargar por fecha + opt = browser.find_element_by_id(self.g.SAT['date']) + opt.click() + self.util.sleep() + if data['search_rfc']: + if data['type_search'] == 1: + txt = browser.find_element_by_id(self.g.SAT['receptor']) + else: + txt = browser.find_element_by_id(self.g.SAT['emisor']) + txt.send_keys(data['search_rfc']) + # Emitidas + if data['type_invoice'] == 1: + year = int(data['search_year']) + month = int(data['search_month']) + dates = self.util.get_dates(year, month) + txt = browser.find_element_by_id(self.g.SAT['date_from']) + arg = "document.getElementsByName('{}')[0]." \ + "removeAttribute('disabled');".format( + self.g.SAT['date_from_name']) + browser.execute_script(arg) + txt.send_keys(dates[0]) + txt = browser.find_element_by_id(self.g.SAT['date_to']) + arg = "document.getElementsByName('{}')[0]." \ + "removeAttribute('disabled');".format( + self.g.SAT['date_to_name']) + browser.execute_script(arg) + txt.send_keys(dates[1]) + # Recibidas + else: + #~ combos = browser.find_elements_by_class_name( + #~ self.g.SAT['combos']) + #~ combos[0].click() + combo = browser.find_element_by_id(self.g.SAT['year']) + combo = browser.find_element_by_id( + 'sbToggle_{}'.format(combo.get_attribute('sb'))) + combo.click() + self.util.sleep(2) + link = browser.find_element_by_link_text( + data['search_year']) + link.click() + self.util.sleep(2) + combo = browser.find_element_by_id(self.g.SAT['month']) + combo = browser.find_element_by_id( + 'sbToggle_{}'.format(combo.get_attribute('sb'))) + combo.click() + self.util.sleep(2) + link = browser.find_element_by_link_text( + data['search_month']) + link.click() + self.util.sleep(2) + if data['search_day'] != '00': + combo = browser.find_element_by_id(self.g.SAT['day']) + sb = combo.get_attribute('sb') + combo = browser.find_element_by_id( + 'sbToggle_{}'.format(sb)) + combo.click() + self.util.sleep() + if data['search_month'] == data['search_day']: + links = browser.find_elements_by_link_text( + data['search_day']) + for l in links: + p = l.find_element_by_xpath( + '..').find_element_by_xpath('..') + sb2 = p.get_attribute('id') + if sb in sb2: + link = l + break + else: + link = browser.find_element_by_link_text( + data['search_day']) + link.click() + self.util.sleep() + + browser.find_element_by_id(self.g.SAT['submit']).click() + sec = 3 + if data['type_invoice'] != 1 and data['search_day'] == '00': + sec = 15 + self.util.sleep(sec) + # Bug del SAT + if data['type_invoice'] != 1 and data['search_day'] != '00': + combo = browser.find_element_by_id(self.g.SAT['day']) + sb = combo.get_attribute('sb') + combo = browser.find_element_by_id( + 'sbToggle_{}'.format(sb)) + combo.click() + self.util.sleep(2) + if data['search_month'] == data['search_day']: + links = browser.find_elements_by_link_text( + data['search_day']) + for l in links: + p = l.find_element_by_xpath( + '..').find_element_by_xpath('..') + sb2 = p.get_attribute('id') + if sb in sb2: + link = l + break + else: + link = browser.find_element_by_link_text( + data['search_day']) + link.click() + self.util.sleep(2) + browser.find_element_by_id(self.g.SAT['submit']).click() + self.util.sleep(sec) + elif data['type_invoice'] == 2 and data['sat_month']: + return self._download_sat_month(data, browser) + + try: + found = True + content = browser.find_elements_by_class_name( + self.g.SAT['subtitle']) + for c in content: + if self.g.SAT['found'] in c.get_attribute('innerHTML') \ + and c.is_displayed(): + found = False + break + except Exception as e: + print (str(e)) + + if found: + docs = browser.find_elements_by_name(self.g.SAT['download']) + t = len(docs) + pb = self._get_object('progressbar') + pb['maximum'] = t + pb.start() + for i, v in enumerate(docs): + msg = 'Factura {} de {}'.format(i+1, t) + pb['value'] = i + 1 + self._set('msg_user', msg, True) + download = self.g.SAT['page_cfdi'].format( + v.get_attribute('onclick').split("'")[1]) + browser.get(download) + pb['value'] = 0 + pb.stop() + self.util.sleep() + else: + self._set('msg_user', 'Sin facturas...', True) + except Exception as e: + print (e) + finally: + try: + self._set('msg_user', 'Desconectando...', True) + link = browser.find_element_by_partial_link_text('Cerrar Sesi') + link.click() + except: + pass + finally: + browser.close() + self._set('msg_user', 'Desconectado...') + return From a7caabe571f892d384db1cdce9e032ba80d0a46d Mon Sep 17 00:00:00 2001 From: Patricio Paez Date: Sat, 28 Feb 2015 18:51:59 -0600 Subject: [PATCH 005/167] Usar DescargaSAT en script - Crear descarga.py como script para ejecutar desde consola - Crear pwd.sample como plantilla del archivo de credenciales --- descarga.py | 71 +++++++++++++++++++++++++++++++++++++++++++++++++++++ pwd.sample | 3 +++ 2 files changed, 74 insertions(+) create mode 100755 descarga.py create mode 100644 pwd.sample diff --git a/descarga.py b/descarga.py new file mode 100755 index 0000000..e49026e --- /dev/null +++ b/descarga.py @@ -0,0 +1,71 @@ +import time +from unittest.mock import Mock +from unittest.mock import MagicMock + +from pyutil import DescargaSAT + + +def _set(widget_name, message, flag=True): + print(message) + +def sleep(sec=1): + time.sleep(sec) + +def main(): + page_init = 'https://cfdiau.sat.gob.mx/nidp/app/login?id=SATUPCFDiCon&' \ + 'sid=0&option=credential&sid=0' + page_cfdi = 'https://portalcfdi.facturaelectronica.sat.gob.mx/{}' + + rfc, pwd = open('pwd').readline()[:-1].split() + data = {'type_invoice': 0, # recibidas + 'type_search': 0, # por fecha + 'user_sat': {'target_sat': 'cfdi', + 'user_sat': rfc, + 'password': pwd}, + 'search_uuid': '', + 'search_rfc': '', + 'search_year': '2014', + 'search_month': '10', + 'search_day': '00', + 'sat_month': '' + } + + app = Mock() + app._set.side_effect = _set + + pb = MagicMock() + app._get_object.return_value = pb + app.util.sleep = sleep + + app.g.SAT = { + 'ftp': 'ftp2.sat.gob.mx', + 'folder': '/Certificados/FEA', + 'form_login': 'IDPLogin', + 'user': 'Ecom_User_ID', + 'password': 'Ecom_Password', + 'date': 'ctl00_MainContent_RdoFechas', + 'date_from': 'ctl00_MainContent_CldFechaInicial2_Calendario_text', + 'date_from_name': 'ctl00$MainContent$CldFechaInicial2$Calendario_text', + 'date_to': 'ctl00_MainContent_CldFechaFinal2_Calendario_text', + 'date_to_name': 'ctl00$MainContent$CldFechaFinal2$Calendario_text', + 'year': 'DdlAnio', + 'month': 'ctl00_MainContent_CldFecha_DdlMes', + 'day': 'ctl00_MainContent_CldFecha_DdlDia', + 'submit': 'ctl00_MainContent_BtnBusqueda', + 'download': 'BtnDescarga', + 'emisor': 'ctl00_MainContent_TxtRfcReceptor', + 'receptor': 'ctl00_MainContent_TxtRfcReceptor', + 'uuid': 'ctl00_MainContent_TxtUUID', + 'combos': 'sbToggle', + 'found': 'No existen registros que cumplan con los criterios de', + 'subtitle': 'subtitle', + 'page_init': page_init, + 'page_cfdi': page_cfdi, + 'page_receptor': page_cfdi.format('ConsultaReceptor.aspx'), + 'page_emisor': page_cfdi.format('ConsultaEmisor.aspx'), + } + descarga = DescargaSAT(data, app) + + +if __name__ == '__main__': + main() diff --git a/pwd.sample b/pwd.sample new file mode 100644 index 0000000..273bf02 --- /dev/null +++ b/pwd.sample @@ -0,0 +1,3 @@ +rfc ciec +# escribe en el renglón de arriba tu RFC y tu CIEC +# separados por un espacio From 47ae8386cd47c355b93087349e059e61c1583b3b Mon Sep 17 00:00:00 2001 From: Patricio Paez Date: Sun, 1 Mar 2015 07:10:41 -0600 Subject: [PATCH 006/167] =?UTF-8?q?Crear=20m=C3=A9todo=20=5Fdownload=5Fsat?= =?UTF-8?q?()?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Alineando con la definición propuesta en el issue #25 --- pyutil.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/pyutil.py b/pyutil.py index eadd60a..2042a99 100644 --- a/pyutil.py +++ b/pyutil.py @@ -1653,8 +1653,18 @@ def _format_date(self, date_string): class DescargaSAT(object): - def __init__(this, data, app): - self = app + def __init__(self, data, app): + self.app = app + self._download_sat(data) + + def _download_sat(self, data): + '''Descarga CFDIs del SAT a una carpeta local + + Es copia sin refactorizar de _download_sat() + en admincfdi.Application''' + + self = self.app + self._set('msg_user', 'Abriendo Firefox...', True) page_query = self.g.SAT['page_receptor'] if data['type_invoice'] == 1: From 99047603b92139b95ada98cd745df98f48b52c8e Mon Sep 17 00:00:00 2001 From: Patricio Paez Date: Sun, 1 Mar 2015 07:44:21 -0600 Subject: [PATCH 007/167] =?UTF-8?q?Agregar=20par=C3=A1metros=20opcionales?= =?UTF-8?q?=20para=20la=20l=C3=ADnea=20de=20comando?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Ayuda con -h - Se ofrecen valores predeterminados --- descarga.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/descarga.py b/descarga.py index e49026e..c70eced 100755 --- a/descarga.py +++ b/descarga.py @@ -1,3 +1,4 @@ +import argparse import time from unittest.mock import Mock from unittest.mock import MagicMock @@ -11,7 +12,29 @@ def _set(widget_name, message, flag=True): def sleep(sec=1): time.sleep(sec) +def process_command_line_arguments(): + parser = argparse.ArgumentParser(description='Descarga CFDIs del SAT a una carpeta local') + + default_archivo_credenciales = 'pwd' + help = 'Archivo con credenciales para el SAT. ' \ + 'RFC y CIEC en el primer renglón y ' \ + 'separadas por un espacio. ' \ + 'El predeterminado es %(default)s' + parser.add_argument('--archivo-de-credenciales', + help=help, default=default_archivo_credenciales) + + default_carpeta_destino = 'cfdi' + help = 'Carpeta local para guardar los CFDIs descargados ' \ + 'El predeterminado es %(default)s' + parser.add_argument('--carpeta-destino', + help=help, default=default_carpeta_destino) + + args=parser.parse_args() + return args + def main(): + args = process_command_line_arguments() + page_init = 'https://cfdiau.sat.gob.mx/nidp/app/login?id=SATUPCFDiCon&' \ 'sid=0&option=credential&sid=0' page_cfdi = 'https://portalcfdi.facturaelectronica.sat.gob.mx/{}' From ab989c1f70e108ec878b206c657becab76774090 Mon Sep 17 00:00:00 2001 From: Patricio Paez Date: Sun, 1 Mar 2015 08:11:17 -0600 Subject: [PATCH 008/167] =?UTF-8?q?Usar=20ambos=20par=C3=A1metros=20de=20l?= =?UTF-8?q?=C3=ADnea=20de=20comando?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - La carpeta destino tiene que ser una ruta absoluta. Si es relativa, el navegador la ignora y utiliza su valor predeterminado de descargas :( --- descarga.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/descarga.py b/descarga.py index c70eced..9af5c32 100755 --- a/descarga.py +++ b/descarga.py @@ -39,10 +39,10 @@ def main(): 'sid=0&option=credential&sid=0' page_cfdi = 'https://portalcfdi.facturaelectronica.sat.gob.mx/{}' - rfc, pwd = open('pwd').readline()[:-1].split() + rfc, pwd = open(args.archivo_de_credenciales).readline()[:-1].split() data = {'type_invoice': 0, # recibidas 'type_search': 0, # por fecha - 'user_sat': {'target_sat': 'cfdi', + 'user_sat': {'target_sat': args.carpeta_destino, 'user_sat': rfc, 'password': pwd}, 'search_uuid': '', From dd35ccd7c9373da25db042751e6118177665db9c Mon Sep 17 00:00:00 2001 From: Patricio Paez Date: Sun, 1 Mar 2015 09:13:40 -0600 Subject: [PATCH 009/167] Explicar validate_download_sat() - Las validaciones que hace - El diccionario que construye - La tupla que regresa --- admincfdi.py | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/admincfdi.py b/admincfdi.py index 809fd72..a647fcb 100644 --- a/admincfdi.py +++ b/admincfdi.py @@ -631,6 +631,38 @@ def _download_sat_month(self, data, browser): return def _validate_download_sat(self): + '''Valida requisitos y crea datos para descarga + + Las validaciones son: + + - Al menos un par RFC y CIEC ha sido registrado + - Un par RFC y CIEC está seleccionado + - La UUID no es nula y tiene 36 caracteres, + si se seleccionó búsqueda por UUID + - El RFC del emisor tiene 12 o 13 + caracteres, si se proporciona + + Si falta alguna de estas condiciones, + se abre un diálogo con un texto informativo + y un botón 'OK' en el ambiente gráfico + y se regresa (False, {}) + + Si las validaciones pasan, se construye un + diccionario ``data`` con estas llaves: + + - user_sat + - type_invoice + - type_search + - search_uuid + - search_rfc + - search_year + - search_month + - search_day + - sat_month + + La función regresa (True, data) + ''' + data = {} if not self.users_sat: msg = 'Agrega un RFC y contraseña a consultar' From 056484c8e7a086f7368cda2558543c7ccf97093d Mon Sep 17 00:00:00 2001 From: Patricio Paez Date: Sun, 1 Mar 2015 10:03:11 -0600 Subject: [PATCH 010/167] Explicar los valores de data --- admincfdi.py | 36 +++++++++++++++++++++++++----------- 1 file changed, 25 insertions(+), 11 deletions(-) diff --git a/admincfdi.py b/admincfdi.py index a647fcb..c875533 100644 --- a/admincfdi.py +++ b/admincfdi.py @@ -648,17 +648,31 @@ def _validate_download_sat(self): y se regresa (False, {}) Si las validaciones pasan, se construye un - diccionario ``data`` con estas llaves: - - - user_sat - - type_invoice - - type_search - - search_uuid - - search_rfc - - search_year - - search_month - - search_day - - sat_month + diccionario ``data`` con estas llaves y valores: + + - user_sat: un diccionario con el + RFC, la CIEC y la carpeta destino + que se seleccionaron, con las llaves + ``user_sat``, ``password`` y ``target_sat``. + - type_invoice: La selección hecha en + *Tipo de consulta*: 0 facturas recibidas, + 1 facturas emitidas + - type_search: La selección hecha en *Tipo + de búsqueda*: 0 por fecha, + 1 por folio fiscal (UUID) + - search_uuid: el valor llenado para *UUID*, + es cadena vacía por omisión + - search_rfc: el valor llenado en *RFC Emisor*, + es cadena vacía por omisión + - search_year: el valor seleccionado en *Año* + como cadena, es el año en curso por omisión + - search_month: el valor seleccionado en *Mes* + como cadena, es el mes en curso por omisión + - search_day: el valor seleccionado en *Día* + como cadena, es '00' por omisión o si sat_month + es verdadero. + - sat_month: Representa la caja a la izquierda de + *Descargar mes completo por día*. La función regresa (True, data) ''' From 40a828abf089115b82390e52f751d2e743e696ba Mon Sep 17 00:00:00 2001 From: Patricio Paez Date: Sun, 1 Mar 2015 10:04:13 -0600 Subject: [PATCH 011/167] Correcciones --- admincfdi.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/admincfdi.py b/admincfdi.py index c875533..8c0a2a0 100644 --- a/admincfdi.py +++ b/admincfdi.py @@ -635,10 +635,13 @@ def _validate_download_sat(self): Las validaciones son: - - Al menos un par RFC y CIEC ha sido registrado - - Un par RFC y CIEC está seleccionado + - Al menos una tríada RFC, CIEC y carpeta + destino ha sido registrada + - Una tríada RFC, CIEC y carpeta destino + está seleccionada - La UUID no es nula y tiene 36 caracteres, - si se seleccionó búsqueda por UUID + si se seleccionó *Buscar por folio + fiscal (UUID)* - El RFC del emisor tiene 12 o 13 caracteres, si se proporciona From 7ad27964a9957f3562eb6e9bc6166a7326c9ad85 Mon Sep 17 00:00:00 2001 From: Patricio Paez Date: Sun, 1 Mar 2015 12:51:49 -0600 Subject: [PATCH 012/167] =?UTF-8?q?Agregar=20par=C3=A1metros=20opcionales?= =?UTF-8?q?=20a=C3=B1o,=20mes=20y=20d=C3=ADa?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Los valores predeterminados igual que en la aplicación admincfdi --- descarga.py | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/descarga.py b/descarga.py index 9af5c32..34fd401 100755 --- a/descarga.py +++ b/descarga.py @@ -1,5 +1,6 @@ import argparse import time +import datetime from unittest.mock import Mock from unittest.mock import MagicMock @@ -29,6 +30,20 @@ def process_command_line_arguments(): parser.add_argument('--carpeta-destino', help=help, default=default_carpeta_destino) + today = datetime.date.today() + help = 'Año. El valor por omisión es el año en curso' + parser.add_argument('--año', + help=help, default=str(today.year)) + + help = 'Mes. El valor por omisión es el mes en curso' + parser.add_argument('--mes', + help=help, default='{:02d}'.format(today.month)) + + help = "Día. El valor por omisión es '00', " \ + 'significa no usar el día en la búsqueda' + parser.add_argument('--día', + help=help, default='00') + args=parser.parse_args() return args @@ -47,9 +62,9 @@ def main(): 'password': pwd}, 'search_uuid': '', 'search_rfc': '', - 'search_year': '2014', - 'search_month': '10', - 'search_day': '00', + 'search_year': args.año, + 'search_month': args.mes, + 'search_day': args.día, 'sat_month': '' } From dc3b0a2d01338b5795db428b7da920c26cfae3b1 Mon Sep 17 00:00:00 2001 From: Patricio Paez Date: Sun, 1 Mar 2015 14:01:22 -0600 Subject: [PATCH 013/167] =?UTF-8?q?Correcci=C3=B3n?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- admincfdi.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/admincfdi.py b/admincfdi.py index 8c0a2a0..8f1da8c 100644 --- a/admincfdi.py +++ b/admincfdi.py @@ -658,7 +658,7 @@ def _validate_download_sat(self): que se seleccionaron, con las llaves ``user_sat``, ``password`` y ``target_sat``. - type_invoice: La selección hecha en - *Tipo de consulta*: 0 facturas recibidas, + *Tipo de consulta*: 2 facturas recibidas, 1 facturas emitidas - type_search: La selección hecha en *Tipo de búsqueda*: 0 por fecha, From faf7aeef8d6236029fd66345bcf72a031b6f154e Mon Sep 17 00:00:00 2001 From: Patricio Paez Date: Sun, 1 Mar 2015 14:03:04 -0600 Subject: [PATCH 014/167] =?UTF-8?q?Agregar=20par=C3=A1metros=20opcionales?= =?UTF-8?q?=20facturas-emitidas=20y=20uuid?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - El uso de uuid cambia type_search a 1 --- descarga.py | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/descarga.py b/descarga.py index 34fd401..462cb5c 100755 --- a/descarga.py +++ b/descarga.py @@ -30,6 +30,18 @@ def process_command_line_arguments(): parser.add_argument('--carpeta-destino', help=help, default=default_carpeta_destino) + help = 'Descargar facturas emitidas. ' \ + 'Por omisión se descargan facturas recibidas' + parser.add_argument('--facturas-emitidas', + action='store_const', const=1, + help=help, default=2) + + help = 'UUID. Por omisión no se usa en la búsqueda. ' \ + 'Esta opción tiene precedencia sobre las demás ' \ + 'opciones de búsqueda.' + parser.add_argument('--uuid', + help=help, default='') + today = datetime.date.today() help = 'Año. El valor por omisión es el año en curso' parser.add_argument('--año', @@ -55,12 +67,12 @@ def main(): page_cfdi = 'https://portalcfdi.facturaelectronica.sat.gob.mx/{}' rfc, pwd = open(args.archivo_de_credenciales).readline()[:-1].split() - data = {'type_invoice': 0, # recibidas - 'type_search': 0, # por fecha + data = {'type_invoice': args.facturas_emitidas, + 'type_search': 1 * (args.uuid != ''), 'user_sat': {'target_sat': args.carpeta_destino, 'user_sat': rfc, 'password': pwd}, - 'search_uuid': '', + 'search_uuid': args.uuid, 'search_rfc': '', 'search_year': args.año, 'search_month': args.mes, From d60dae98633ccffedb564d6ed0cedba2a3695ca3 Mon Sep 17 00:00:00 2001 From: Patricio Paez Date: Sun, 1 Mar 2015 18:24:16 -0600 Subject: [PATCH 015/167] =?UTF-8?q?Completar=20el=20mock=20de=20la=20aplic?= =?UTF-8?q?aci=C3=B3n?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- descarga.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/descarga.py b/descarga.py index 462cb5c..e992f13 100755 --- a/descarga.py +++ b/descarga.py @@ -13,6 +13,13 @@ def _set(widget_name, message, flag=True): def sleep(sec=1): time.sleep(sec) +def get_dates(year, month): + import calendar + days = calendar.monthrange(year, month)[1] + d1 = '01/{:02d}/{}'.format(month, year) + d2 = '{}/{:02d}/{}'.format(days, month, year) + return d1, d2 + def process_command_line_arguments(): parser = argparse.ArgumentParser(description='Descarga CFDIs del SAT a una carpeta local') @@ -86,6 +93,7 @@ def main(): pb = MagicMock() app._get_object.return_value = pb app.util.sleep = sleep + app.util.get_dates.side_effect = get_dates app.g.SAT = { 'ftp': 'ftp2.sat.gob.mx', From 31aefaf535f30432e922229b4dc81db59c7cf1c3 Mon Sep 17 00:00:00 2001 From: Patricio Paez Date: Sun, 1 Mar 2015 18:37:31 -0600 Subject: [PATCH 016/167] =?UTF-8?q?Agregar=20par=C3=A1metro=20opcional=20r?= =?UTF-8?q?fc-emisor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- descarga.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/descarga.py b/descarga.py index e992f13..8ccfddc 100755 --- a/descarga.py +++ b/descarga.py @@ -49,6 +49,10 @@ def process_command_line_arguments(): parser.add_argument('--uuid', help=help, default='') + help = 'RFC del emisor. Por omisión no se usa en la búsqueda.' + parser.add_argument('--rfc-emisor', + help=help, default='') + today = datetime.date.today() help = 'Año. El valor por omisión es el año en curso' parser.add_argument('--año', @@ -80,7 +84,7 @@ def main(): 'user_sat': rfc, 'password': pwd}, 'search_uuid': args.uuid, - 'search_rfc': '', + 'search_rfc': args.rfc_emisor, 'search_year': args.año, 'search_month': args.mes, 'search_day': args.día, From 43d7f093889845dcfe47b1b4d15ae78e8b4421a9 Mon Sep 17 00:00:00 2001 From: Patricio Paez Date: Sun, 1 Mar 2015 20:19:13 -0600 Subject: [PATCH 017/167] Renombrar self a app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Se preserva self para referir a _download_sat_month() que se va a agregar ahora - Ya podemos refactorizar _download_sat() porque podemos probar funcionalmente todas las opciones de búsqueda --- pyutil.py | 103 ++++++++++++++++++++++++++---------------------------- 1 file changed, 50 insertions(+), 53 deletions(-) diff --git a/pyutil.py b/pyutil.py index 2042a99..c5554a7 100644 --- a/pyutil.py +++ b/pyutil.py @@ -1658,17 +1658,14 @@ def __init__(self, data, app): self._download_sat(data) def _download_sat(self, data): - '''Descarga CFDIs del SAT a una carpeta local + 'Descarga CFDIs del SAT a una carpeta local' - Es copia sin refactorizar de _download_sat() - en admincfdi.Application''' + app = self.app - self = self.app - - self._set('msg_user', 'Abriendo Firefox...', True) - page_query = self.g.SAT['page_receptor'] + app._set('msg_user', 'Abriendo Firefox...', True) + page_query = app.g.SAT['page_receptor'] if data['type_invoice'] == 1: - page_query = self.g.SAT['page_emisor'] + page_query = app.g.SAT['page_emisor'] # To prevent download dialog profile = webdriver.FirefoxProfile() profile.set_preference( @@ -1708,80 +1705,80 @@ def _download_sat(self, data): 'browser.download.animateNotifications', False) try: browser = webdriver.Firefox(profile) - self._set('msg_user', 'Conectando...', True) - browser.get(self.g.SAT['page_init']) - txt = browser.find_element_by_name(self.g.SAT['user']) + app._set('msg_user', 'Conectando...', True) + browser.get(app.g.SAT['page_init']) + txt = browser.find_element_by_name(app.g.SAT['user']) txt.send_keys(data['user_sat']['user_sat']) - txt = browser.find_element_by_name(self.g.SAT['password']) + txt = browser.find_element_by_name(app.g.SAT['password']) txt.send_keys(data['user_sat']['password']) txt.submit() - self.util.sleep(3) - self._set('msg_user', 'Conectado...', True) + app.util.sleep(3) + app._set('msg_user', 'Conectado...', True) browser.get(page_query) - self.util.sleep(3) - self._set('msg_user', 'Buscando...', True) + app.util.sleep(3) + app._set('msg_user', 'Buscando...', True) if data['type_search'] == 1: - txt = browser.find_element_by_id(self.g.SAT['uuid']) + txt = browser.find_element_by_id(app.g.SAT['uuid']) txt.click() txt.send_keys(data['search_uuid']) else: # Descargar por fecha - opt = browser.find_element_by_id(self.g.SAT['date']) + opt = browser.find_element_by_id(app.g.SAT['date']) opt.click() - self.util.sleep() + app.util.sleep() if data['search_rfc']: if data['type_search'] == 1: - txt = browser.find_element_by_id(self.g.SAT['receptor']) + txt = browser.find_element_by_id(app.g.SAT['receptor']) else: - txt = browser.find_element_by_id(self.g.SAT['emisor']) + txt = browser.find_element_by_id(app.g.SAT['emisor']) txt.send_keys(data['search_rfc']) # Emitidas if data['type_invoice'] == 1: year = int(data['search_year']) month = int(data['search_month']) - dates = self.util.get_dates(year, month) - txt = browser.find_element_by_id(self.g.SAT['date_from']) + dates = app.util.get_dates(year, month) + txt = browser.find_element_by_id(app.g.SAT['date_from']) arg = "document.getElementsByName('{}')[0]." \ "removeAttribute('disabled');".format( - self.g.SAT['date_from_name']) + app.g.SAT['date_from_name']) browser.execute_script(arg) txt.send_keys(dates[0]) - txt = browser.find_element_by_id(self.g.SAT['date_to']) + txt = browser.find_element_by_id(app.g.SAT['date_to']) arg = "document.getElementsByName('{}')[0]." \ "removeAttribute('disabled');".format( - self.g.SAT['date_to_name']) + app.g.SAT['date_to_name']) browser.execute_script(arg) txt.send_keys(dates[1]) # Recibidas else: #~ combos = browser.find_elements_by_class_name( - #~ self.g.SAT['combos']) + #~ app.g.SAT['combos']) #~ combos[0].click() - combo = browser.find_element_by_id(self.g.SAT['year']) + combo = browser.find_element_by_id(app.g.SAT['year']) combo = browser.find_element_by_id( 'sbToggle_{}'.format(combo.get_attribute('sb'))) combo.click() - self.util.sleep(2) + app.util.sleep(2) link = browser.find_element_by_link_text( data['search_year']) link.click() - self.util.sleep(2) - combo = browser.find_element_by_id(self.g.SAT['month']) + app.util.sleep(2) + combo = browser.find_element_by_id(app.g.SAT['month']) combo = browser.find_element_by_id( 'sbToggle_{}'.format(combo.get_attribute('sb'))) combo.click() - self.util.sleep(2) + app.util.sleep(2) link = browser.find_element_by_link_text( data['search_month']) link.click() - self.util.sleep(2) + app.util.sleep(2) if data['search_day'] != '00': - combo = browser.find_element_by_id(self.g.SAT['day']) + combo = browser.find_element_by_id(app.g.SAT['day']) sb = combo.get_attribute('sb') combo = browser.find_element_by_id( 'sbToggle_{}'.format(sb)) combo.click() - self.util.sleep() + app.util.sleep() if data['search_month'] == data['search_day']: links = browser.find_elements_by_link_text( data['search_day']) @@ -1796,21 +1793,21 @@ def _download_sat(self, data): link = browser.find_element_by_link_text( data['search_day']) link.click() - self.util.sleep() + app.util.sleep() - browser.find_element_by_id(self.g.SAT['submit']).click() + browser.find_element_by_id(app.g.SAT['submit']).click() sec = 3 if data['type_invoice'] != 1 and data['search_day'] == '00': sec = 15 - self.util.sleep(sec) + app.util.sleep(sec) # Bug del SAT if data['type_invoice'] != 1 and data['search_day'] != '00': - combo = browser.find_element_by_id(self.g.SAT['day']) + combo = browser.find_element_by_id(app.g.SAT['day']) sb = combo.get_attribute('sb') combo = browser.find_element_by_id( 'sbToggle_{}'.format(sb)) combo.click() - self.util.sleep(2) + app.util.sleep(2) if data['search_month'] == data['search_day']: links = browser.find_elements_by_link_text( data['search_day']) @@ -1825,18 +1822,18 @@ def _download_sat(self, data): link = browser.find_element_by_link_text( data['search_day']) link.click() - self.util.sleep(2) - browser.find_element_by_id(self.g.SAT['submit']).click() - self.util.sleep(sec) + app.util.sleep(2) + browser.find_element_by_id(app.g.SAT['submit']).click() + app.util.sleep(sec) elif data['type_invoice'] == 2 and data['sat_month']: return self._download_sat_month(data, browser) try: found = True content = browser.find_elements_by_class_name( - self.g.SAT['subtitle']) + app.g.SAT['subtitle']) for c in content: - if self.g.SAT['found'] in c.get_attribute('innerHTML') \ + if app.g.SAT['found'] in c.get_attribute('innerHTML') \ and c.is_displayed(): found = False break @@ -1844,33 +1841,33 @@ def _download_sat(self, data): print (str(e)) if found: - docs = browser.find_elements_by_name(self.g.SAT['download']) + docs = browser.find_elements_by_name(app.g.SAT['download']) t = len(docs) - pb = self._get_object('progressbar') + pb = app._get_object('progressbar') pb['maximum'] = t pb.start() for i, v in enumerate(docs): msg = 'Factura {} de {}'.format(i+1, t) pb['value'] = i + 1 - self._set('msg_user', msg, True) - download = self.g.SAT['page_cfdi'].format( + app._set('msg_user', msg, True) + download = app.g.SAT['page_cfdi'].format( v.get_attribute('onclick').split("'")[1]) browser.get(download) pb['value'] = 0 pb.stop() - self.util.sleep() + app.util.sleep() else: - self._set('msg_user', 'Sin facturas...', True) + app._set('msg_user', 'Sin facturas...', True) except Exception as e: print (e) finally: try: - self._set('msg_user', 'Desconectando...', True) + app._set('msg_user', 'Desconectando...', True) link = browser.find_element_by_partial_link_text('Cerrar Sesi') link.click() except: pass finally: browser.close() - self._set('msg_user', 'Desconectado...') + app._set('msg_user', 'Desconectado...') return From 1e1e326c9d03cb8ba78760d2134f14fdd0a42cd9 Mon Sep 17 00:00:00 2001 From: Patricio Paez Date: Sun, 1 Mar 2015 21:07:51 -0600 Subject: [PATCH 018/167] Crear _download_sat_month() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Es copia sin refactorizar de _download_sat_month() - Como método de DescargaSAT --- pyutil.py | 52 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/pyutil.py b/pyutil.py index c5554a7..303b986 100644 --- a/pyutil.py +++ b/pyutil.py @@ -1871,3 +1871,55 @@ def _download_sat(self, data): browser.close() app._set('msg_user', 'Desconectado...') return + + def _download_sat_month(self, data, browser): + '''Descarga CFDIs del SAT a una carpeta local + + Es copia sin refactorizar de _download_sat_month() + en admincfdi.Application''' + + self = self.app + + year = int(data['search_year']) + month = int(data['search_month']) + days_month = self.util.get_days(year, month) + 1 + days = ['%02d' % x for x in range(1, days_month)] + for d in days: + combo = browser.find_element_by_id(self.g.SAT['day']) + sb = combo.get_attribute('sb') + combo = browser.find_element_by_id('sbToggle_{}'.format(sb)) + combo.click() + self.util.sleep(2) + if data['search_month'] == d: + links = browser.find_elements_by_link_text(d) + for l in links: + p = l.find_element_by_xpath( + '..').find_element_by_xpath('..') + sb2 = p.get_attribute('id') + if sb in sb2: + link = l + break + else: + link = browser.find_element_by_link_text(d) + link.click() + self.util.sleep(2) + browser.find_element_by_id(self.g.SAT['submit']).click() + self.util.sleep(3) + docs = browser.find_elements_by_name(self.g.SAT['download']) + if docs: + t = len(docs) + pb = self._get_object('progressbar') + pb['maximum'] = t + pb.start() + for i, v in enumerate(docs): + msg = 'Factura {} de {}'.format(i+1, t) + pb['value'] = i + 1 + self._set('msg_user', msg, True) + download = self.g.SAT['page_cfdi'].format( + v.get_attribute('onclick').split("'")[1]) + browser.get(download) + self.util.sleep() + pb['value'] = 0 + pb.stop() + self.util.sleep() + return From de349aee92a2f2c7a684493983af42ae815b548a Mon Sep 17 00:00:00 2001 From: Patricio Paez Date: Sun, 1 Mar 2015 21:11:42 -0600 Subject: [PATCH 019/167] =?UTF-8?q?Agregar=20par=C3=A1metro=20opcional=20m?= =?UTF-8?q?es-completo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- descarga.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/descarga.py b/descarga.py index 8ccfddc..8464aef 100755 --- a/descarga.py +++ b/descarga.py @@ -67,6 +67,10 @@ def process_command_line_arguments(): parser.add_argument('--día', help=help, default='00') + help = 'Mes completo por día. Por omisión no se usa en la búsqueda.' + parser.add_argument('--mes-completo', action='store_const', const=True, + help=help, default=False) + args=parser.parse_args() return args @@ -88,7 +92,7 @@ def main(): 'search_year': args.año, 'search_month': args.mes, 'search_day': args.día, - 'sat_month': '' + 'sat_month': args.mes_completo } app = Mock() From 2bf6e1c0494d4cb12ba30d2b5dcf8826c5ecfccc Mon Sep 17 00:00:00 2001 From: Patricio Paez Date: Sun, 1 Mar 2015 21:12:48 -0600 Subject: [PATCH 020/167] =?UTF-8?q?Completar=20el=20mock=20de=20la=20aplic?= =?UTF-8?q?aci=C3=B3n?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- descarga.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/descarga.py b/descarga.py index 8464aef..5cb2a6f 100755 --- a/descarga.py +++ b/descarga.py @@ -20,6 +20,14 @@ def get_dates(year, month): d2 = '{}/{:02d}/{}'.format(days, month, year) return d1, d2 +def get_days(year, month): + import calendar + if isinstance(year, str): + year = int(year) + if isinstance(month, str): + month = int(month) + return calendar.monthrange(year, month)[1] + def process_command_line_arguments(): parser = argparse.ArgumentParser(description='Descarga CFDIs del SAT a una carpeta local') @@ -102,6 +110,7 @@ def main(): app._get_object.return_value = pb app.util.sleep = sleep app.util.get_dates.side_effect = get_dates + app.util.get_days.side_effect = get_days app.g.SAT = { 'ftp': 'ftp2.sat.gob.mx', From b92e58cbae082b2961d1067ba73fd5fbd3f6b7f1 Mon Sep 17 00:00:00 2001 From: Patricio Paez Date: Mon, 2 Mar 2015 06:25:33 -0600 Subject: [PATCH 021/167] Agrupar el bloque main --- descarga.py | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/descarga.py b/descarga.py index 5cb2a6f..fbd0ed4 100755 --- a/descarga.py +++ b/descarga.py @@ -83,26 +83,11 @@ def process_command_line_arguments(): return args def main(): - args = process_command_line_arguments() page_init = 'https://cfdiau.sat.gob.mx/nidp/app/login?id=SATUPCFDiCon&' \ 'sid=0&option=credential&sid=0' page_cfdi = 'https://portalcfdi.facturaelectronica.sat.gob.mx/{}' - rfc, pwd = open(args.archivo_de_credenciales).readline()[:-1].split() - data = {'type_invoice': args.facturas_emitidas, - 'type_search': 1 * (args.uuid != ''), - 'user_sat': {'target_sat': args.carpeta_destino, - 'user_sat': rfc, - 'password': pwd}, - 'search_uuid': args.uuid, - 'search_rfc': args.rfc_emisor, - 'search_year': args.año, - 'search_month': args.mes, - 'search_day': args.día, - 'sat_month': args.mes_completo - } - app = Mock() app._set.side_effect = _set @@ -139,6 +124,21 @@ def main(): 'page_receptor': page_cfdi.format('ConsultaReceptor.aspx'), 'page_emisor': page_cfdi.format('ConsultaEmisor.aspx'), } + + args = process_command_line_arguments() + rfc, pwd = open(args.archivo_de_credenciales).readline()[:-1].split() + data = {'type_invoice': args.facturas_emitidas, + 'type_search': 1 * (args.uuid != ''), + 'user_sat': {'target_sat': args.carpeta_destino, + 'user_sat': rfc, + 'password': pwd}, + 'search_uuid': args.uuid, + 'search_rfc': args.rfc_emisor, + 'search_year': args.año, + 'search_month': args.mes, + 'search_day': args.día, + 'sat_month': args.mes_completo + } descarga = DescargaSAT(data, app) From 0285ad5a9df9af7dfad05d5624331535b66949a5 Mon Sep 17 00:00:00 2001 From: Patricio Paez Date: Mon, 2 Mar 2015 06:33:03 -0600 Subject: [PATCH 022/167] Mover app afuera de main MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Se hará innecesaria mediante refactorización en DescargaSAT --- descarga.py | 82 ++++++++++++++++++++++++++--------------------------- 1 file changed, 41 insertions(+), 41 deletions(-) diff --git a/descarga.py b/descarga.py index fbd0ed4..9b24716 100755 --- a/descarga.py +++ b/descarga.py @@ -28,6 +28,47 @@ def get_days(year, month): month = int(month) return calendar.monthrange(year, month)[1] +page_init = 'https://cfdiau.sat.gob.mx/nidp/app/login?id=SATUPCFDiCon&' \ +'sid=0&option=credential&sid=0' +page_cfdi = 'https://portalcfdi.facturaelectronica.sat.gob.mx/{}' + +app = Mock() +app._set.side_effect = _set + +pb = MagicMock() +app._get_object.return_value = pb +app.util.sleep = sleep +app.util.get_dates.side_effect = get_dates +app.util.get_days.side_effect = get_days + +app.g.SAT = { + 'ftp': 'ftp2.sat.gob.mx', + 'folder': '/Certificados/FEA', + 'form_login': 'IDPLogin', + 'user': 'Ecom_User_ID', + 'password': 'Ecom_Password', + 'date': 'ctl00_MainContent_RdoFechas', + 'date_from': 'ctl00_MainContent_CldFechaInicial2_Calendario_text', + 'date_from_name': 'ctl00$MainContent$CldFechaInicial2$Calendario_text', + 'date_to': 'ctl00_MainContent_CldFechaFinal2_Calendario_text', + 'date_to_name': 'ctl00$MainContent$CldFechaFinal2$Calendario_text', + 'year': 'DdlAnio', + 'month': 'ctl00_MainContent_CldFecha_DdlMes', + 'day': 'ctl00_MainContent_CldFecha_DdlDia', + 'submit': 'ctl00_MainContent_BtnBusqueda', + 'download': 'BtnDescarga', + 'emisor': 'ctl00_MainContent_TxtRfcReceptor', + 'receptor': 'ctl00_MainContent_TxtRfcReceptor', + 'uuid': 'ctl00_MainContent_TxtUUID', + 'combos': 'sbToggle', + 'found': 'No existen registros que cumplan con los criterios de', + 'subtitle': 'subtitle', + 'page_init': page_init, + 'page_cfdi': page_cfdi, + 'page_receptor': page_cfdi.format('ConsultaReceptor.aspx'), + 'page_emisor': page_cfdi.format('ConsultaEmisor.aspx'), + } + def process_command_line_arguments(): parser = argparse.ArgumentParser(description='Descarga CFDIs del SAT a una carpeta local') @@ -84,47 +125,6 @@ def process_command_line_arguments(): def main(): - page_init = 'https://cfdiau.sat.gob.mx/nidp/app/login?id=SATUPCFDiCon&' \ - 'sid=0&option=credential&sid=0' - page_cfdi = 'https://portalcfdi.facturaelectronica.sat.gob.mx/{}' - - app = Mock() - app._set.side_effect = _set - - pb = MagicMock() - app._get_object.return_value = pb - app.util.sleep = sleep - app.util.get_dates.side_effect = get_dates - app.util.get_days.side_effect = get_days - - app.g.SAT = { - 'ftp': 'ftp2.sat.gob.mx', - 'folder': '/Certificados/FEA', - 'form_login': 'IDPLogin', - 'user': 'Ecom_User_ID', - 'password': 'Ecom_Password', - 'date': 'ctl00_MainContent_RdoFechas', - 'date_from': 'ctl00_MainContent_CldFechaInicial2_Calendario_text', - 'date_from_name': 'ctl00$MainContent$CldFechaInicial2$Calendario_text', - 'date_to': 'ctl00_MainContent_CldFechaFinal2_Calendario_text', - 'date_to_name': 'ctl00$MainContent$CldFechaFinal2$Calendario_text', - 'year': 'DdlAnio', - 'month': 'ctl00_MainContent_CldFecha_DdlMes', - 'day': 'ctl00_MainContent_CldFecha_DdlDia', - 'submit': 'ctl00_MainContent_BtnBusqueda', - 'download': 'BtnDescarga', - 'emisor': 'ctl00_MainContent_TxtRfcReceptor', - 'receptor': 'ctl00_MainContent_TxtRfcReceptor', - 'uuid': 'ctl00_MainContent_TxtUUID', - 'combos': 'sbToggle', - 'found': 'No existen registros que cumplan con los criterios de', - 'subtitle': 'subtitle', - 'page_init': page_init, - 'page_cfdi': page_cfdi, - 'page_receptor': page_cfdi.format('ConsultaReceptor.aspx'), - 'page_emisor': page_cfdi.format('ConsultaEmisor.aspx'), - } - args = process_command_line_arguments() rfc, pwd = open(args.archivo_de_credenciales).readline()[:-1].split() data = {'type_invoice': args.facturas_emitidas, From c7788fcd802c81d249f9fbf7c765f72fb884388c Mon Sep 17 00:00:00 2001 From: Patricio Paez Date: Mon, 2 Mar 2015 17:13:10 -0600 Subject: [PATCH 023/167] Renombrar self a app --- pyutil.py | 29 ++++++++++++++--------------- 1 file changed, 14 insertions(+), 15 deletions(-) diff --git a/pyutil.py b/pyutil.py index 303b986..fdc20b3 100644 --- a/pyutil.py +++ b/pyutil.py @@ -1875,21 +1875,20 @@ def _download_sat(self, data): def _download_sat_month(self, data, browser): '''Descarga CFDIs del SAT a una carpeta local - Es copia sin refactorizar de _download_sat_month() - en admincfdi.Application''' + Todos los CFDIs del mes selecionado''' - self = self.app + app = self.app year = int(data['search_year']) month = int(data['search_month']) - days_month = self.util.get_days(year, month) + 1 + days_month = app.util.get_days(year, month) + 1 days = ['%02d' % x for x in range(1, days_month)] for d in days: - combo = browser.find_element_by_id(self.g.SAT['day']) + combo = browser.find_element_by_id(app.g.SAT['day']) sb = combo.get_attribute('sb') combo = browser.find_element_by_id('sbToggle_{}'.format(sb)) combo.click() - self.util.sleep(2) + app.util.sleep(2) if data['search_month'] == d: links = browser.find_elements_by_link_text(d) for l in links: @@ -1902,24 +1901,24 @@ def _download_sat_month(self, data, browser): else: link = browser.find_element_by_link_text(d) link.click() - self.util.sleep(2) - browser.find_element_by_id(self.g.SAT['submit']).click() - self.util.sleep(3) - docs = browser.find_elements_by_name(self.g.SAT['download']) + app.util.sleep(2) + browser.find_element_by_id(app.g.SAT['submit']).click() + app.util.sleep(3) + docs = browser.find_elements_by_name(app.g.SAT['download']) if docs: t = len(docs) - pb = self._get_object('progressbar') + pb = app._get_object('progressbar') pb['maximum'] = t pb.start() for i, v in enumerate(docs): msg = 'Factura {} de {}'.format(i+1, t) pb['value'] = i + 1 - self._set('msg_user', msg, True) - download = self.g.SAT['page_cfdi'].format( + app._set('msg_user', msg, True) + download = app.g.SAT['page_cfdi'].format( v.get_attribute('onclick').split("'")[1]) browser.get(download) - self.util.sleep() + app.util.sleep() pb['value'] = 0 pb.stop() - self.util.sleep() + app.util.sleep() return From f88679c797601728f2c970e6b60409f544a961c8 Mon Sep 17 00:00:00 2001 From: Patricio Paez Date: Mon, 2 Mar 2015 20:24:45 -0600 Subject: [PATCH 024/167] Utilizar pyutil.Util - Usar get_dates, get_days, sleep - Remover del mock de aplication --- descarga.py | 21 --------------------- pyutil.py | 43 ++++++++++++++++++++++--------------------- 2 files changed, 22 insertions(+), 42 deletions(-) diff --git a/descarga.py b/descarga.py index 9b24716..6cab221 100755 --- a/descarga.py +++ b/descarga.py @@ -10,24 +10,6 @@ def _set(widget_name, message, flag=True): print(message) -def sleep(sec=1): - time.sleep(sec) - -def get_dates(year, month): - import calendar - days = calendar.monthrange(year, month)[1] - d1 = '01/{:02d}/{}'.format(month, year) - d2 = '{}/{:02d}/{}'.format(days, month, year) - return d1, d2 - -def get_days(year, month): - import calendar - if isinstance(year, str): - year = int(year) - if isinstance(month, str): - month = int(month) - return calendar.monthrange(year, month)[1] - page_init = 'https://cfdiau.sat.gob.mx/nidp/app/login?id=SATUPCFDiCon&' \ 'sid=0&option=credential&sid=0' page_cfdi = 'https://portalcfdi.facturaelectronica.sat.gob.mx/{}' @@ -37,9 +19,6 @@ def get_days(year, month): pb = MagicMock() app._get_object.return_value = pb -app.util.sleep = sleep -app.util.get_dates.side_effect = get_dates -app.util.get_days.side_effect = get_days app.g.SAT = { 'ftp': 'ftp2.sat.gob.mx', diff --git a/pyutil.py b/pyutil.py index fdc20b3..22d5040 100644 --- a/pyutil.py +++ b/pyutil.py @@ -1654,6 +1654,7 @@ def _format_date(self, date_string): class DescargaSAT(object): def __init__(self, data, app): + self.util = Util() self.app = app self._download_sat(data) @@ -1712,10 +1713,10 @@ def _download_sat(self, data): txt = browser.find_element_by_name(app.g.SAT['password']) txt.send_keys(data['user_sat']['password']) txt.submit() - app.util.sleep(3) + self.util.sleep(3) app._set('msg_user', 'Conectado...', True) browser.get(page_query) - app.util.sleep(3) + self.util.sleep(3) app._set('msg_user', 'Buscando...', True) if data['type_search'] == 1: txt = browser.find_element_by_id(app.g.SAT['uuid']) @@ -1725,7 +1726,7 @@ def _download_sat(self, data): # Descargar por fecha opt = browser.find_element_by_id(app.g.SAT['date']) opt.click() - app.util.sleep() + self.util.sleep() if data['search_rfc']: if data['type_search'] == 1: txt = browser.find_element_by_id(app.g.SAT['receptor']) @@ -1736,7 +1737,7 @@ def _download_sat(self, data): if data['type_invoice'] == 1: year = int(data['search_year']) month = int(data['search_month']) - dates = app.util.get_dates(year, month) + dates = self.util.get_dates(year, month) txt = browser.find_element_by_id(app.g.SAT['date_from']) arg = "document.getElementsByName('{}')[0]." \ "removeAttribute('disabled');".format( @@ -1758,27 +1759,27 @@ def _download_sat(self, data): combo = browser.find_element_by_id( 'sbToggle_{}'.format(combo.get_attribute('sb'))) combo.click() - app.util.sleep(2) + self.util.sleep(2) link = browser.find_element_by_link_text( data['search_year']) link.click() - app.util.sleep(2) + self.util.sleep(2) combo = browser.find_element_by_id(app.g.SAT['month']) combo = browser.find_element_by_id( 'sbToggle_{}'.format(combo.get_attribute('sb'))) combo.click() - app.util.sleep(2) + self.util.sleep(2) link = browser.find_element_by_link_text( data['search_month']) link.click() - app.util.sleep(2) + self.util.sleep(2) if data['search_day'] != '00': combo = browser.find_element_by_id(app.g.SAT['day']) sb = combo.get_attribute('sb') combo = browser.find_element_by_id( 'sbToggle_{}'.format(sb)) combo.click() - app.util.sleep() + self.util.sleep() if data['search_month'] == data['search_day']: links = browser.find_elements_by_link_text( data['search_day']) @@ -1793,13 +1794,13 @@ def _download_sat(self, data): link = browser.find_element_by_link_text( data['search_day']) link.click() - app.util.sleep() + self.util.sleep() browser.find_element_by_id(app.g.SAT['submit']).click() sec = 3 if data['type_invoice'] != 1 and data['search_day'] == '00': sec = 15 - app.util.sleep(sec) + self.util.sleep(sec) # Bug del SAT if data['type_invoice'] != 1 and data['search_day'] != '00': combo = browser.find_element_by_id(app.g.SAT['day']) @@ -1807,7 +1808,7 @@ def _download_sat(self, data): combo = browser.find_element_by_id( 'sbToggle_{}'.format(sb)) combo.click() - app.util.sleep(2) + self.util.sleep(2) if data['search_month'] == data['search_day']: links = browser.find_elements_by_link_text( data['search_day']) @@ -1822,9 +1823,9 @@ def _download_sat(self, data): link = browser.find_element_by_link_text( data['search_day']) link.click() - app.util.sleep(2) + self.util.sleep(2) browser.find_element_by_id(app.g.SAT['submit']).click() - app.util.sleep(sec) + self.util.sleep(sec) elif data['type_invoice'] == 2 and data['sat_month']: return self._download_sat_month(data, browser) @@ -1855,7 +1856,7 @@ def _download_sat(self, data): browser.get(download) pb['value'] = 0 pb.stop() - app.util.sleep() + self.util.sleep() else: app._set('msg_user', 'Sin facturas...', True) except Exception as e: @@ -1881,14 +1882,14 @@ def _download_sat_month(self, data, browser): year = int(data['search_year']) month = int(data['search_month']) - days_month = app.util.get_days(year, month) + 1 + days_month = self.util.get_days(year, month) + 1 days = ['%02d' % x for x in range(1, days_month)] for d in days: combo = browser.find_element_by_id(app.g.SAT['day']) sb = combo.get_attribute('sb') combo = browser.find_element_by_id('sbToggle_{}'.format(sb)) combo.click() - app.util.sleep(2) + self.util.sleep(2) if data['search_month'] == d: links = browser.find_elements_by_link_text(d) for l in links: @@ -1901,9 +1902,9 @@ def _download_sat_month(self, data, browser): else: link = browser.find_element_by_link_text(d) link.click() - app.util.sleep(2) + self.util.sleep(2) browser.find_element_by_id(app.g.SAT['submit']).click() - app.util.sleep(3) + self.util.sleep(3) docs = browser.find_elements_by_name(app.g.SAT['download']) if docs: t = len(docs) @@ -1917,8 +1918,8 @@ def _download_sat_month(self, data, browser): download = app.g.SAT['page_cfdi'].format( v.get_attribute('onclick').split("'")[1]) browser.get(download) - app.util.sleep() + self.util.sleep() pb['value'] = 0 pb.stop() - app.util.sleep() + self.util.sleep() return From 76626dcb764eb9e7ba61db92dbce4163599ae077 Mon Sep 17 00:00:00 2001 From: Patricio Paez Date: Mon, 2 Mar 2015 20:24:45 -0600 Subject: [PATCH 025/167] Utilizar values.Global - Se usan el atributo SAT, page_init y page_cfdi - Remover del mock de aplication --- descarga.py | 32 ----------------------------- pyutil.py | 58 +++++++++++++++++++++++++++-------------------------- 2 files changed, 30 insertions(+), 60 deletions(-) diff --git a/descarga.py b/descarga.py index 6cab221..bdecbac 100755 --- a/descarga.py +++ b/descarga.py @@ -10,44 +10,12 @@ def _set(widget_name, message, flag=True): print(message) -page_init = 'https://cfdiau.sat.gob.mx/nidp/app/login?id=SATUPCFDiCon&' \ -'sid=0&option=credential&sid=0' -page_cfdi = 'https://portalcfdi.facturaelectronica.sat.gob.mx/{}' - app = Mock() app._set.side_effect = _set pb = MagicMock() app._get_object.return_value = pb -app.g.SAT = { - 'ftp': 'ftp2.sat.gob.mx', - 'folder': '/Certificados/FEA', - 'form_login': 'IDPLogin', - 'user': 'Ecom_User_ID', - 'password': 'Ecom_Password', - 'date': 'ctl00_MainContent_RdoFechas', - 'date_from': 'ctl00_MainContent_CldFechaInicial2_Calendario_text', - 'date_from_name': 'ctl00$MainContent$CldFechaInicial2$Calendario_text', - 'date_to': 'ctl00_MainContent_CldFechaFinal2_Calendario_text', - 'date_to_name': 'ctl00$MainContent$CldFechaFinal2$Calendario_text', - 'year': 'DdlAnio', - 'month': 'ctl00_MainContent_CldFecha_DdlMes', - 'day': 'ctl00_MainContent_CldFecha_DdlDia', - 'submit': 'ctl00_MainContent_BtnBusqueda', - 'download': 'BtnDescarga', - 'emisor': 'ctl00_MainContent_TxtRfcReceptor', - 'receptor': 'ctl00_MainContent_TxtRfcReceptor', - 'uuid': 'ctl00_MainContent_TxtUUID', - 'combos': 'sbToggle', - 'found': 'No existen registros que cumplan con los criterios de', - 'subtitle': 'subtitle', - 'page_init': page_init, - 'page_cfdi': page_cfdi, - 'page_receptor': page_cfdi.format('ConsultaReceptor.aspx'), - 'page_emisor': page_cfdi.format('ConsultaEmisor.aspx'), - } - def process_command_line_arguments(): parser = argparse.ArgumentParser(description='Descarga CFDIs del SAT a una carpeta local') diff --git a/pyutil.py b/pyutil.py index 22d5040..dd9dae3 100644 --- a/pyutil.py +++ b/pyutil.py @@ -37,6 +37,7 @@ from tkinter import messagebox from pysimplesoap.client import SoapClient, SoapFault from selenium import webdriver +from values import Global try: from subprocess import DEVNULL except ImportError: @@ -1654,6 +1655,7 @@ def _format_date(self, date_string): class DescargaSAT(object): def __init__(self, data, app): + self.g = Global() self.util = Util() self.app = app self._download_sat(data) @@ -1664,9 +1666,9 @@ def _download_sat(self, data): app = self.app app._set('msg_user', 'Abriendo Firefox...', True) - page_query = app.g.SAT['page_receptor'] + page_query = self.g.SAT['page_receptor'] if data['type_invoice'] == 1: - page_query = app.g.SAT['page_emisor'] + page_query = self.g.SAT['page_emisor'] # To prevent download dialog profile = webdriver.FirefoxProfile() profile.set_preference( @@ -1707,10 +1709,10 @@ def _download_sat(self, data): try: browser = webdriver.Firefox(profile) app._set('msg_user', 'Conectando...', True) - browser.get(app.g.SAT['page_init']) - txt = browser.find_element_by_name(app.g.SAT['user']) + browser.get(self.g.SAT['page_init']) + txt = browser.find_element_by_name(self.g.SAT['user']) txt.send_keys(data['user_sat']['user_sat']) - txt = browser.find_element_by_name(app.g.SAT['password']) + txt = browser.find_element_by_name(self.g.SAT['password']) txt.send_keys(data['user_sat']['password']) txt.submit() self.util.sleep(3) @@ -1719,43 +1721,43 @@ def _download_sat(self, data): self.util.sleep(3) app._set('msg_user', 'Buscando...', True) if data['type_search'] == 1: - txt = browser.find_element_by_id(app.g.SAT['uuid']) + txt = browser.find_element_by_id(self.g.SAT['uuid']) txt.click() txt.send_keys(data['search_uuid']) else: # Descargar por fecha - opt = browser.find_element_by_id(app.g.SAT['date']) + opt = browser.find_element_by_id(self.g.SAT['date']) opt.click() self.util.sleep() if data['search_rfc']: if data['type_search'] == 1: - txt = browser.find_element_by_id(app.g.SAT['receptor']) + txt = browser.find_element_by_id(self.g.SAT['receptor']) else: - txt = browser.find_element_by_id(app.g.SAT['emisor']) + txt = browser.find_element_by_id(self.g.SAT['emisor']) txt.send_keys(data['search_rfc']) # Emitidas if data['type_invoice'] == 1: year = int(data['search_year']) month = int(data['search_month']) dates = self.util.get_dates(year, month) - txt = browser.find_element_by_id(app.g.SAT['date_from']) + txt = browser.find_element_by_id(self.g.SAT['date_from']) arg = "document.getElementsByName('{}')[0]." \ "removeAttribute('disabled');".format( - app.g.SAT['date_from_name']) + self.g.SAT['date_from_name']) browser.execute_script(arg) txt.send_keys(dates[0]) - txt = browser.find_element_by_id(app.g.SAT['date_to']) + txt = browser.find_element_by_id(self.g.SAT['date_to']) arg = "document.getElementsByName('{}')[0]." \ "removeAttribute('disabled');".format( - app.g.SAT['date_to_name']) + self.g.SAT['date_to_name']) browser.execute_script(arg) txt.send_keys(dates[1]) # Recibidas else: #~ combos = browser.find_elements_by_class_name( - #~ app.g.SAT['combos']) + #~ self.g.SAT['combos']) #~ combos[0].click() - combo = browser.find_element_by_id(app.g.SAT['year']) + combo = browser.find_element_by_id(self.g.SAT['year']) combo = browser.find_element_by_id( 'sbToggle_{}'.format(combo.get_attribute('sb'))) combo.click() @@ -1764,7 +1766,7 @@ def _download_sat(self, data): data['search_year']) link.click() self.util.sleep(2) - combo = browser.find_element_by_id(app.g.SAT['month']) + combo = browser.find_element_by_id(self.g.SAT['month']) combo = browser.find_element_by_id( 'sbToggle_{}'.format(combo.get_attribute('sb'))) combo.click() @@ -1774,7 +1776,7 @@ def _download_sat(self, data): link.click() self.util.sleep(2) if data['search_day'] != '00': - combo = browser.find_element_by_id(app.g.SAT['day']) + combo = browser.find_element_by_id(self.g.SAT['day']) sb = combo.get_attribute('sb') combo = browser.find_element_by_id( 'sbToggle_{}'.format(sb)) @@ -1796,14 +1798,14 @@ def _download_sat(self, data): link.click() self.util.sleep() - browser.find_element_by_id(app.g.SAT['submit']).click() + browser.find_element_by_id(self.g.SAT['submit']).click() sec = 3 if data['type_invoice'] != 1 and data['search_day'] == '00': sec = 15 self.util.sleep(sec) # Bug del SAT if data['type_invoice'] != 1 and data['search_day'] != '00': - combo = browser.find_element_by_id(app.g.SAT['day']) + combo = browser.find_element_by_id(self.g.SAT['day']) sb = combo.get_attribute('sb') combo = browser.find_element_by_id( 'sbToggle_{}'.format(sb)) @@ -1824,7 +1826,7 @@ def _download_sat(self, data): data['search_day']) link.click() self.util.sleep(2) - browser.find_element_by_id(app.g.SAT['submit']).click() + browser.find_element_by_id(self.g.SAT['submit']).click() self.util.sleep(sec) elif data['type_invoice'] == 2 and data['sat_month']: return self._download_sat_month(data, browser) @@ -1832,9 +1834,9 @@ def _download_sat(self, data): try: found = True content = browser.find_elements_by_class_name( - app.g.SAT['subtitle']) + self.g.SAT['subtitle']) for c in content: - if app.g.SAT['found'] in c.get_attribute('innerHTML') \ + if self.g.SAT['found'] in c.get_attribute('innerHTML') \ and c.is_displayed(): found = False break @@ -1842,7 +1844,7 @@ def _download_sat(self, data): print (str(e)) if found: - docs = browser.find_elements_by_name(app.g.SAT['download']) + docs = browser.find_elements_by_name(self.g.SAT['download']) t = len(docs) pb = app._get_object('progressbar') pb['maximum'] = t @@ -1851,7 +1853,7 @@ def _download_sat(self, data): msg = 'Factura {} de {}'.format(i+1, t) pb['value'] = i + 1 app._set('msg_user', msg, True) - download = app.g.SAT['page_cfdi'].format( + download = self.g.SAT['page_cfdi'].format( v.get_attribute('onclick').split("'")[1]) browser.get(download) pb['value'] = 0 @@ -1885,7 +1887,7 @@ def _download_sat_month(self, data, browser): days_month = self.util.get_days(year, month) + 1 days = ['%02d' % x for x in range(1, days_month)] for d in days: - combo = browser.find_element_by_id(app.g.SAT['day']) + combo = browser.find_element_by_id(self.g.SAT['day']) sb = combo.get_attribute('sb') combo = browser.find_element_by_id('sbToggle_{}'.format(sb)) combo.click() @@ -1903,9 +1905,9 @@ def _download_sat_month(self, data, browser): link = browser.find_element_by_link_text(d) link.click() self.util.sleep(2) - browser.find_element_by_id(app.g.SAT['submit']).click() + browser.find_element_by_id(self.g.SAT['submit']).click() self.util.sleep(3) - docs = browser.find_elements_by_name(app.g.SAT['download']) + docs = browser.find_elements_by_name(self.g.SAT['download']) if docs: t = len(docs) pb = app._get_object('progressbar') @@ -1915,7 +1917,7 @@ def _download_sat_month(self, data, browser): msg = 'Factura {} de {}'.format(i+1, t) pb['value'] = i + 1 app._set('msg_user', msg, True) - download = app.g.SAT['page_cfdi'].format( + download = self.g.SAT['page_cfdi'].format( v.get_attribute('onclick').split("'")[1]) browser.get(download) self.util.sleep() From 3c005591cd8d0830e87b4e85ff712a20f17a4683 Mon Sep 17 00:00:00 2001 From: Patricio Paez Date: Tue, 3 Mar 2015 06:52:04 -0600 Subject: [PATCH 026/167] =?UTF-8?q?Agregar=20par=C3=A1metro=20opcional=20s?= =?UTF-8?q?tatus=5Fcallback?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - El atributo status es llamado para enviar mensajes - Por omisión los mensajes se envían a stdout - Remover del mock de aplication --- descarga.py | 4 ---- pyutil.py | 21 +++++++++++---------- 2 files changed, 11 insertions(+), 14 deletions(-) diff --git a/descarga.py b/descarga.py index bdecbac..3b7a3b8 100755 --- a/descarga.py +++ b/descarga.py @@ -7,11 +7,7 @@ from pyutil import DescargaSAT -def _set(widget_name, message, flag=True): - print(message) - app = Mock() -app._set.side_effect = _set pb = MagicMock() app._get_object.return_value = pb diff --git a/pyutil.py b/pyutil.py index dd9dae3..ae1ca71 100644 --- a/pyutil.py +++ b/pyutil.py @@ -1654,10 +1654,11 @@ def _format_date(self, date_string): class DescargaSAT(object): - def __init__(self, data, app): + def __init__(self, data, app, status_callback=print): self.g = Global() self.util = Util() self.app = app + self.status = status_callback self._download_sat(data) def _download_sat(self, data): @@ -1665,7 +1666,7 @@ def _download_sat(self, data): app = self.app - app._set('msg_user', 'Abriendo Firefox...', True) + self.status('Abriendo Firefox...') page_query = self.g.SAT['page_receptor'] if data['type_invoice'] == 1: page_query = self.g.SAT['page_emisor'] @@ -1708,7 +1709,7 @@ def _download_sat(self, data): 'browser.download.animateNotifications', False) try: browser = webdriver.Firefox(profile) - app._set('msg_user', 'Conectando...', True) + self.status('Conectando...') browser.get(self.g.SAT['page_init']) txt = browser.find_element_by_name(self.g.SAT['user']) txt.send_keys(data['user_sat']['user_sat']) @@ -1716,10 +1717,10 @@ def _download_sat(self, data): txt.send_keys(data['user_sat']['password']) txt.submit() self.util.sleep(3) - app._set('msg_user', 'Conectado...', True) + self.status('Conectado...') browser.get(page_query) self.util.sleep(3) - app._set('msg_user', 'Buscando...', True) + self.status('Buscando...') if data['type_search'] == 1: txt = browser.find_element_by_id(self.g.SAT['uuid']) txt.click() @@ -1852,7 +1853,7 @@ def _download_sat(self, data): for i, v in enumerate(docs): msg = 'Factura {} de {}'.format(i+1, t) pb['value'] = i + 1 - app._set('msg_user', msg, True) + self.status(msg) download = self.g.SAT['page_cfdi'].format( v.get_attribute('onclick').split("'")[1]) browser.get(download) @@ -1860,19 +1861,19 @@ def _download_sat(self, data): pb.stop() self.util.sleep() else: - app._set('msg_user', 'Sin facturas...', True) + self.status('Sin facturas...') except Exception as e: print (e) finally: try: - app._set('msg_user', 'Desconectando...', True) + self.status('Desconectando...') link = browser.find_element_by_partial_link_text('Cerrar Sesi') link.click() except: pass finally: browser.close() - app._set('msg_user', 'Desconectado...') + self.status('Desconectado...') return def _download_sat_month(self, data, browser): @@ -1916,7 +1917,7 @@ def _download_sat_month(self, data, browser): for i, v in enumerate(docs): msg = 'Factura {} de {}'.format(i+1, t) pb['value'] = i + 1 - app._set('msg_user', msg, True) + self.status(msg) download = self.g.SAT['page_cfdi'].format( v.get_attribute('onclick').split("'")[1]) browser.get(download) From 2f66bb0e4394751b0cc4af80a0196f7f6bf0b37b Mon Sep 17 00:00:00 2001 From: Patricio Paez Date: Tue, 3 Mar 2015 06:52:04 -0600 Subject: [PATCH 027/167] =?UTF-8?q?Agregar=20par=C3=A1metro=20opcional=20d?= =?UTF-8?q?ownload=5Fcallback?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - El atributo progress es llamado para proporcionar valor y máximo a un control gráfico de tipo barra de progreso - Por omisión se envían valor y máximo a stdout - Remover pb del mock de aplication --- descarga.py | 4 ---- pyutil.py | 20 +++++++------------- 2 files changed, 7 insertions(+), 17 deletions(-) diff --git a/descarga.py b/descarga.py index 3b7a3b8..24c9bdd 100755 --- a/descarga.py +++ b/descarga.py @@ -2,16 +2,12 @@ import time import datetime from unittest.mock import Mock -from unittest.mock import MagicMock from pyutil import DescargaSAT app = Mock() -pb = MagicMock() -app._get_object.return_value = pb - def process_command_line_arguments(): parser = argparse.ArgumentParser(description='Descarga CFDIs del SAT a una carpeta local') diff --git a/pyutil.py b/pyutil.py index ae1ca71..6713bd6 100644 --- a/pyutil.py +++ b/pyutil.py @@ -1654,11 +1654,13 @@ def _format_date(self, date_string): class DescargaSAT(object): - def __init__(self, data, app, status_callback=print): + def __init__(self, data, app, status_callback=print, + download_callback=print): self.g = Global() self.util = Util() self.app = app self.status = status_callback + self.progress = download_callback self._download_sat(data) def _download_sat(self, data): @@ -1847,18 +1849,14 @@ def _download_sat(self, data): if found: docs = browser.find_elements_by_name(self.g.SAT['download']) t = len(docs) - pb = app._get_object('progressbar') - pb['maximum'] = t - pb.start() for i, v in enumerate(docs): msg = 'Factura {} de {}'.format(i+1, t) - pb['value'] = i + 1 + self.progress(i + 1, t) self.status(msg) download = self.g.SAT['page_cfdi'].format( v.get_attribute('onclick').split("'")[1]) browser.get(download) - pb['value'] = 0 - pb.stop() + self.progress(0, t) self.util.sleep() else: self.status('Sin facturas...') @@ -1911,18 +1909,14 @@ def _download_sat_month(self, data, browser): docs = browser.find_elements_by_name(self.g.SAT['download']) if docs: t = len(docs) - pb = app._get_object('progressbar') - pb['maximum'] = t - pb.start() for i, v in enumerate(docs): msg = 'Factura {} de {}'.format(i+1, t) - pb['value'] = i + 1 + self.progress(i + 1, t) self.status(msg) download = self.g.SAT['page_cfdi'].format( v.get_attribute('onclick').split("'")[1]) browser.get(download) self.util.sleep() - pb['value'] = 0 - pb.stop() + self.progress(0, t) self.util.sleep() return From 1c043901c7ed947c6da70944fcc447cbc244fec0 Mon Sep 17 00:00:00 2001 From: Patricio Paez Date: Tue, 3 Mar 2015 07:33:41 -0600 Subject: [PATCH 028/167] =?UTF-8?q?Remover=20par=C3=A1metro=20app?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remover el mock --- descarga.py | 5 +---- pyutil.py | 7 +------ 2 files changed, 2 insertions(+), 10 deletions(-) diff --git a/descarga.py b/descarga.py index 24c9bdd..4fe4a0e 100755 --- a/descarga.py +++ b/descarga.py @@ -1,13 +1,10 @@ import argparse import time import datetime -from unittest.mock import Mock from pyutil import DescargaSAT -app = Mock() - def process_command_line_arguments(): parser = argparse.ArgumentParser(description='Descarga CFDIs del SAT a una carpeta local') @@ -78,7 +75,7 @@ def main(): 'search_day': args.día, 'sat_month': args.mes_completo } - descarga = DescargaSAT(data, app) + descarga = DescargaSAT(data) if __name__ == '__main__': diff --git a/pyutil.py b/pyutil.py index 6713bd6..37a44e3 100644 --- a/pyutil.py +++ b/pyutil.py @@ -1654,11 +1654,10 @@ def _format_date(self, date_string): class DescargaSAT(object): - def __init__(self, data, app, status_callback=print, + def __init__(self, data, status_callback=print, download_callback=print): self.g = Global() self.util = Util() - self.app = app self.status = status_callback self.progress = download_callback self._download_sat(data) @@ -1666,8 +1665,6 @@ def __init__(self, data, app, status_callback=print, def _download_sat(self, data): 'Descarga CFDIs del SAT a una carpeta local' - app = self.app - self.status('Abriendo Firefox...') page_query = self.g.SAT['page_receptor'] if data['type_invoice'] == 1: @@ -1879,8 +1876,6 @@ def _download_sat_month(self, data, browser): Todos los CFDIs del mes selecionado''' - app = self.app - year = int(data['search_year']) month = int(data['search_month']) days_month = self.util.get_days(year, month) + 1 From 60afc3d1443d36d562b35605e36ab46abf3c0781 Mon Sep 17 00:00:00 2001 From: Patricio Paez Date: Tue, 3 Mar 2015 17:57:37 -0600 Subject: [PATCH 029/167] admincfdi usa DescargaSAT MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Agregar los métodos msg_user() y progress() como wrapper a los widgets - Usar estos métodos en los parámetros opcionales status_callback y download_callback --- admincfdi.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/admincfdi.py b/admincfdi.py index 8f1da8c..62a55ed 100644 --- a/admincfdi.py +++ b/admincfdi.py @@ -18,6 +18,7 @@ from pyutil import Mail from pyutil import LibO from pyutil import CFDIPDF +from pyutil import DescargaSAT from values import Global @@ -367,11 +368,21 @@ def combo_month_click(self, event): self.util.combo_values(combo, days, 0) return + def msg_user(self, msg): + self._set('msg_user', msg, True) + + def progress(self, value, maximum): + pb = self._get_object('progressbar') + pb['value'] = value + pb['maximum'] = maximum + self.parent.update_idletasks() + def button_download_sat_click(self): ok, data = self._validate_download_sat() if not ok: return - self._download_sat(data) + DescargaSAT(data, status_callback=self.msg_user, + download_callback=self.progress) return def _download_sat(self, data): From ad7c6521c2c3c852f0788f32ba80e40f4ea9706a Mon Sep 17 00:00:00 2001 From: Patricio Paez Date: Tue, 3 Mar 2015 18:00:49 -0600 Subject: [PATCH 030/167] Remover descarga de admincfdi MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - _download_sat() y _download_sat_month() ya están incluidas en pyutil.DescargaSAT --- admincfdi.py | 256 --------------------------------------------------- 1 file changed, 256 deletions(-) diff --git a/admincfdi.py b/admincfdi.py index 62a55ed..c2e1a31 100644 --- a/admincfdi.py +++ b/admincfdi.py @@ -385,262 +385,6 @@ def button_download_sat_click(self): download_callback=self.progress) return - def _download_sat(self, data): - self._set('msg_user', 'Abriendo Firefox...', True) - page_query = self.g.SAT['page_receptor'] - if data['type_invoice'] == 1: - page_query = self.g.SAT['page_emisor'] - # To prevent download dialog - profile = webdriver.FirefoxProfile() - profile.set_preference( - 'browser.download.folderList', 2) - profile.set_preference( - 'browser.download.manager.showWhenStarting', False) - profile.set_preference( - 'browser.helperApps.alwaysAsk.force', False) - profile.set_preference( - 'browser.helperApps.neverAsk.saveToDisk', - 'text/xml, application/octet-stream, application/xml') - profile.set_preference( - 'browser.download.dir', data['user_sat']['target_sat']) - # mrE - desactivar telemetry - profile.set_preference( - 'toolkit.telemetry.prompted', 2) - profile.set_preference( - 'toolkit.telemetry.rejected', True) - profile.set_preference( - 'toolkit.telemetry.enabled', False) - profile.set_preference( - 'datareporting.healthreport.service.enabled', False) - profile.set_preference( - 'datareporting.healthreport.uploadEnabled', False) - profile.set_preference( - 'datareporting.healthreport.service.firstRun', False) - profile.set_preference( - 'datareporting.healthreport.logging.consoleEnabled', False) - profile.set_preference( - 'datareporting.policy.dataSubmissionEnabled', False) - profile.set_preference( - 'datareporting.policy.dataSubmissionPolicyResponseType', 'accepted-info-bar-dismissed') - #profile.set_preference( - # 'datareporting.policy.dataSubmissionPolicyAccepted'; False) # este me marca error, why? - #oculta la gran flecha animada al descargar - profile.set_preference( - 'browser.download.animateNotifications', False) - try: - browser = webdriver.Firefox(profile) - self._set('msg_user', 'Conectando...', True) - browser.get(self.g.SAT['page_init']) - txt = browser.find_element_by_name(self.g.SAT['user']) - txt.send_keys(data['user_sat']['user_sat']) - txt = browser.find_element_by_name(self.g.SAT['password']) - txt.send_keys(data['user_sat']['password']) - txt.submit() - self.util.sleep(3) - self._set('msg_user', 'Conectado...', True) - browser.get(page_query) - self.util.sleep(3) - self._set('msg_user', 'Buscando...', True) - if data['type_search'] == 1: - txt = browser.find_element_by_id(self.g.SAT['uuid']) - txt.click() - txt.send_keys(data['search_uuid']) - else: - # Descargar por fecha - opt = browser.find_element_by_id(self.g.SAT['date']) - opt.click() - self.util.sleep() - if data['search_rfc']: - if data['type_search'] == 1: - txt = browser.find_element_by_id(self.g.SAT['receptor']) - else: - txt = browser.find_element_by_id(self.g.SAT['emisor']) - txt.send_keys(data['search_rfc']) - # Emitidas - if data['type_invoice'] == 1: - year = int(data['search_year']) - month = int(data['search_month']) - dates = self.util.get_dates(year, month) - txt = browser.find_element_by_id(self.g.SAT['date_from']) - arg = "document.getElementsByName('{}')[0]." \ - "removeAttribute('disabled');".format( - self.g.SAT['date_from_name']) - browser.execute_script(arg) - txt.send_keys(dates[0]) - txt = browser.find_element_by_id(self.g.SAT['date_to']) - arg = "document.getElementsByName('{}')[0]." \ - "removeAttribute('disabled');".format( - self.g.SAT['date_to_name']) - browser.execute_script(arg) - txt.send_keys(dates[1]) - # Recibidas - else: - #~ combos = browser.find_elements_by_class_name( - #~ self.g.SAT['combos']) - #~ combos[0].click() - combo = browser.find_element_by_id(self.g.SAT['year']) - combo = browser.find_element_by_id( - 'sbToggle_{}'.format(combo.get_attribute('sb'))) - combo.click() - self.util.sleep(2) - link = browser.find_element_by_link_text( - data['search_year']) - link.click() - self.util.sleep(2) - combo = browser.find_element_by_id(self.g.SAT['month']) - combo = browser.find_element_by_id( - 'sbToggle_{}'.format(combo.get_attribute('sb'))) - combo.click() - self.util.sleep(2) - link = browser.find_element_by_link_text( - data['search_month']) - link.click() - self.util.sleep(2) - if data['search_day'] != '00': - combo = browser.find_element_by_id(self.g.SAT['day']) - sb = combo.get_attribute('sb') - combo = browser.find_element_by_id( - 'sbToggle_{}'.format(sb)) - combo.click() - self.util.sleep() - if data['search_month'] == data['search_day']: - links = browser.find_elements_by_link_text( - data['search_day']) - for l in links: - p = l.find_element_by_xpath( - '..').find_element_by_xpath('..') - sb2 = p.get_attribute('id') - if sb in sb2: - link = l - break - else: - link = browser.find_element_by_link_text( - data['search_day']) - link.click() - self.util.sleep() - - browser.find_element_by_id(self.g.SAT['submit']).click() - sec = 3 - if data['type_invoice'] != 1 and data['search_day'] == '00': - sec = 15 - self.util.sleep(sec) - # Bug del SAT - if data['type_invoice'] != 1 and data['search_day'] != '00': - combo = browser.find_element_by_id(self.g.SAT['day']) - sb = combo.get_attribute('sb') - combo = browser.find_element_by_id( - 'sbToggle_{}'.format(sb)) - combo.click() - self.util.sleep(2) - if data['search_month'] == data['search_day']: - links = browser.find_elements_by_link_text( - data['search_day']) - for l in links: - p = l.find_element_by_xpath( - '..').find_element_by_xpath('..') - sb2 = p.get_attribute('id') - if sb in sb2: - link = l - break - else: - link = browser.find_element_by_link_text( - data['search_day']) - link.click() - self.util.sleep(2) - browser.find_element_by_id(self.g.SAT['submit']).click() - self.util.sleep(sec) - elif data['type_invoice'] == 2 and data['sat_month']: - return self._download_sat_month(data, browser) - - try: - found = True - content = browser.find_elements_by_class_name( - self.g.SAT['subtitle']) - for c in content: - if self.g.SAT['found'] in c.get_attribute('innerHTML') \ - and c.is_displayed(): - found = False - break - except Exception as e: - print (str(e)) - - if found: - docs = browser.find_elements_by_name(self.g.SAT['download']) - t = len(docs) - pb = self._get_object('progressbar') - pb['maximum'] = t - pb.start() - for i, v in enumerate(docs): - msg = 'Factura {} de {}'.format(i+1, t) - pb['value'] = i + 1 - self._set('msg_user', msg, True) - download = self.g.SAT['page_cfdi'].format( - v.get_attribute('onclick').split("'")[1]) - browser.get(download) - pb['value'] = 0 - pb.stop() - self.util.sleep() - else: - self._set('msg_user', 'Sin facturas...', True) - except Exception as e: - print (e) - finally: - try: - self._set('msg_user', 'Desconectando...', True) - link = browser.find_element_by_partial_link_text('Cerrar Sesi') - link.click() - except: - pass - finally: - browser.close() - self._set('msg_user', 'Desconectado...') - return - - def _download_sat_month(self, data, browser): - year = int(data['search_year']) - month = int(data['search_month']) - days_month = self.util.get_days(year, month) + 1 - days = ['%02d' % x for x in range(1, days_month)] - for d in days: - combo = browser.find_element_by_id(self.g.SAT['day']) - sb = combo.get_attribute('sb') - combo = browser.find_element_by_id('sbToggle_{}'.format(sb)) - combo.click() - self.util.sleep(2) - if data['search_month'] == d: - links = browser.find_elements_by_link_text(d) - for l in links: - p = l.find_element_by_xpath( - '..').find_element_by_xpath('..') - sb2 = p.get_attribute('id') - if sb in sb2: - link = l - break - else: - link = browser.find_element_by_link_text(d) - link.click() - self.util.sleep(2) - browser.find_element_by_id(self.g.SAT['submit']).click() - self.util.sleep(3) - docs = browser.find_elements_by_name(self.g.SAT['download']) - if docs: - t = len(docs) - pb = self._get_object('progressbar') - pb['maximum'] = t - pb.start() - for i, v in enumerate(docs): - msg = 'Factura {} de {}'.format(i+1, t) - pb['value'] = i + 1 - self._set('msg_user', msg, True) - download = self.g.SAT['page_cfdi'].format( - v.get_attribute('onclick').split("'")[1]) - browser.get(download) - self.util.sleep() - pb['value'] = 0 - pb.stop() - self.util.sleep() - return - def _validate_download_sat(self): '''Valida requisitos y crea datos para descarga From abe590a806415a2dfb09cfd568ea33bd7fc33677 Mon Sep 17 00:00:00 2001 From: Sergio E Gutierrez Date: Wed, 4 Mar 2015 22:13:59 -0600 Subject: [PATCH 031/167] =?UTF-8?q?Fix:=20organiza=20xmls=20con=20nombre?= =?UTF-8?q?=20en=20may=C3=BAsculas?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit También se remueve el módulo glob --- pyutil.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/pyutil.py b/pyutil.py index f460fac..bd28904 100644 --- a/pyutil.py +++ b/pyutil.py @@ -15,7 +15,6 @@ import sys import re import csv -import glob import json import ftplib import time @@ -341,10 +340,11 @@ def path_config(self, path): return target def get_files(self, path, ext='*.xml'): - files = [] - for folder,_,_ in os.walk(path): - files.extend(glob.glob(os.path.join(folder, ext.lower()))) - return tuple(files) + xmls = [] + for folder, _, files in os.walk(path): + pattern = re.compile('\.xml', re.IGNORECASE) + xmls += [os.path.join(folder,f) for f in files if pattern.search(f)] + return tuple(xmls) def join(self, *paths): return os.path.join(*paths) From 641dbcf8871d1444d3a7ac9eb8c226cdba4e2191 Mon Sep 17 00:00:00 2001 From: Sergio E Gutierrez Date: Wed, 4 Mar 2015 22:35:06 -0600 Subject: [PATCH 032/167] Ignorando temporales de vi/vim --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 6fde163..244740d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ *.pyc *.ini *.log +*.swp ======= docs/_build From e08b5c9ea1e239e15e40fbcce26693dd2e7fad84 Mon Sep 17 00:00:00 2001 From: Patricio Paez Date: Thu, 5 Mar 2015 07:38:40 -0600 Subject: [PATCH 033/167] =?UTF-8?q?Renombrar=20opci=C3=B3n=20a=20--mes-com?= =?UTF-8?q?pleto-por-d=C3=ADa?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- descarga.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/descarga.py b/descarga.py index 4fe4a0e..4d4fb1e 100755 --- a/descarga.py +++ b/descarga.py @@ -53,7 +53,7 @@ def process_command_line_arguments(): help=help, default='00') help = 'Mes completo por día. Por omisión no se usa en la búsqueda.' - parser.add_argument('--mes-completo', action='store_const', const=True, + parser.add_argument('--mes-completo-por-día', action='store_const', const=True, help=help, default=False) args=parser.parse_args() @@ -73,7 +73,7 @@ def main(): 'search_year': args.año, 'search_month': args.mes, 'search_day': args.día, - 'sat_month': args.mes_completo + 'sat_month': args.mes_completo_por_día } descarga = DescargaSAT(data) From d86cd15d00c9395c2c37f0fe719bb259d0ad9422 Mon Sep 17 00:00:00 2001 From: Patricio Paez Date: Thu, 5 Mar 2015 07:38:40 -0600 Subject: [PATCH 034/167] Cambiar nombre a credenciales.conf --- descarga.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/descarga.py b/descarga.py index 4d4fb1e..66bf36c 100755 --- a/descarga.py +++ b/descarga.py @@ -1,6 +1,7 @@ import argparse import time import datetime +import os from pyutil import DescargaSAT @@ -8,7 +9,7 @@ def process_command_line_arguments(): parser = argparse.ArgumentParser(description='Descarga CFDIs del SAT a una carpeta local') - default_archivo_credenciales = 'pwd' + default_archivo_credenciales = os.path.join('credenciales.conf') help = 'Archivo con credenciales para el SAT. ' \ 'RFC y CIEC en el primer renglón y ' \ 'separadas por un espacio. ' \ From 2a9019d8d94c4726e9f87182f9dda49dc0419291 Mon Sep 17 00:00:00 2001 From: Patricio Paez Date: Fri, 6 Mar 2015 23:53:15 -0600 Subject: [PATCH 035/167] =?UTF-8?q?Agregar=20par=C3=A1metro=20opcional=20s?= =?UTF-8?q?olicitar-credenciales?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- descarga.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/descarga.py b/descarga.py index 66bf36c..0939cb4 100755 --- a/descarga.py +++ b/descarga.py @@ -17,6 +17,11 @@ def process_command_line_arguments(): parser.add_argument('--archivo-de-credenciales', help=help, default=default_archivo_credenciales) + help = 'Solicitar credenciales para el SAT al inicio. ' + parser.add_argument('--solicitar-credenciales', + action='store_const', const=True, + help=help, default=False) + default_carpeta_destino = 'cfdi' help = 'Carpeta local para guardar los CFDIs descargados ' \ 'El predeterminado es %(default)s' From 79eb3ba6745e1088f217b0af6211e5b9f1f37f9f Mon Sep 17 00:00:00 2001 From: Patricio Paez Date: Fri, 6 Mar 2015 23:54:43 -0600 Subject: [PATCH 036/167] Solicitar las credenciales --- descarga.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/descarga.py b/descarga.py index 0939cb4..945e84c 100755 --- a/descarga.py +++ b/descarga.py @@ -2,6 +2,7 @@ import time import datetime import os +import getpass from pyutil import DescargaSAT @@ -68,7 +69,11 @@ def process_command_line_arguments(): def main(): args = process_command_line_arguments() - rfc, pwd = open(args.archivo_de_credenciales).readline()[:-1].split() + if args.solicitar_credenciales: + rfc = input('RFC: ') + pwd = getpass.getpass('CIEC: ') + else: + rfc, pwd = open(args.archivo_de_credenciales).readline()[:-1].split() data = {'type_invoice': args.facturas_emitidas, 'type_search': 1 * (args.uuid != ''), 'user_sat': {'target_sat': args.carpeta_destino, From 9a67ac8f0c663b678b9e060130efa1dc78b49874 Mon Sep 17 00:00:00 2001 From: Patricio Paez Date: Sat, 7 Mar 2015 00:01:34 -0600 Subject: [PATCH 037/167] Carpeta destino es 'cfdi-descarga' - En la carpeta del usuario --- descarga.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/descarga.py b/descarga.py index 945e84c..b266185 100755 --- a/descarga.py +++ b/descarga.py @@ -23,7 +23,8 @@ def process_command_line_arguments(): action='store_const', const=True, help=help, default=False) - default_carpeta_destino = 'cfdi' + default_carpeta_destino = os.path.join( + os.environ.get('HOME'), 'cfdi-descarga') help = 'Carpeta local para guardar los CFDIs descargados ' \ 'El predeterminado es %(default)s' parser.add_argument('--carpeta-destino', From 9c9260ff1c98b8445721400f83becc1661a651be Mon Sep 17 00:00:00 2001 From: Patricio Paez Date: Sat, 7 Mar 2015 00:04:17 -0600 Subject: [PATCH 038/167] Nombre sin la ruta --- descarga.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/descarga.py b/descarga.py index b266185..dc102eb 100755 --- a/descarga.py +++ b/descarga.py @@ -10,7 +10,7 @@ def process_command_line_arguments(): parser = argparse.ArgumentParser(description='Descarga CFDIs del SAT a una carpeta local') - default_archivo_credenciales = os.path.join('credenciales.conf') + default_archivo_credenciales = 'credenciales.conf' help = 'Archivo con credenciales para el SAT. ' \ 'RFC y CIEC en el primer renglón y ' \ 'separadas por un espacio. ' \ From 3e4dc0a329333883377a7428675c884e2c12676b Mon Sep 17 00:00:00 2001 From: Patricio Paez Date: Sat, 7 Mar 2015 00:11:02 -0600 Subject: [PATCH 039/167] Simplificar la ayuda --- descarga.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/descarga.py b/descarga.py index dc102eb..496d095 100755 --- a/descarga.py +++ b/descarga.py @@ -55,8 +55,7 @@ def process_command_line_arguments(): parser.add_argument('--mes', help=help, default='{:02d}'.format(today.month)) - help = "Día. El valor por omisión es '00', " \ - 'significa no usar el día en la búsqueda' + help = 'Día. Por omisión no se usa en la búsqueda.' parser.add_argument('--día', help=help, default='00') From 6760d33329c1784d6fa71714c905b49cc321f9d3 Mon Sep 17 00:00:00 2001 From: Mauricio Baeza Date: Wed, 25 Mar 2015 00:37:54 -0600 Subject: [PATCH 040/167] Se agrega soporte para generar PDF desde una plantilla CSV --- pyutil.py | 770 ++++++++++++++++++++++++++++++++++++++++++- template/default.csv | 77 +++++ values.py | 4 +- xml2pdf.py | 41 +++ 4 files changed, 889 insertions(+), 3 deletions(-) create mode 100644 template/default.csv create mode 100755 xml2pdf.py diff --git a/pyutil.py b/pyutil.py index d79bc7c..b3a40e6 100644 --- a/pyutil.py +++ b/pyutil.py @@ -37,6 +37,7 @@ from pysimplesoap.client import SoapClient, SoapFault from selenium import webdriver from values import Global +from fpdf import FPDF try: from subprocess import DEVNULL except ImportError: @@ -332,6 +333,10 @@ def get_path_info(self, path, index=-1): else: return data[index] + def replace_extension(self, path, new_ext): + path, _, name, _ = self.get_path_info(path) + return self.join(path, name + new_ext) + def path_join(self, *paths): return os.path.join(*paths) @@ -341,10 +346,10 @@ def path_config(self, path): target = path.replace('/', '\\') return target - def get_files(self, path, ext='*.xml'): + def get_files(self, path, ext='xml'): xmls = [] for folder, _, files in os.walk(path): - pattern = re.compile('\.xml', re.IGNORECASE) + pattern = re.compile('\.{}'.format(ext), re.IGNORECASE) xmls += [os.path.join(folder,f) for f in files if pattern.search(f)] return tuple(xmls) @@ -1915,3 +1920,764 @@ def _download_sat_month(self, data, browser): self.progress(0, t) self.util.sleep() return + + +class CSVPDF(FPDF): + """ + Genera un PDF a partir de un CSV + """ + G = Global() + TITULO1 = { + '2.0': 'Datos CFD', + '2.2': 'Datos CFD', + '3.0': 'Datos CFDI', + '3.2': 'Datos CFDI' + } + TITULO2 = { + '2.0': 'Año Aprobación:', + '2.2': 'Año Aprobación:', + '3.0': 'Serie CSD SAT:', + '3.2': 'Serie CSD SAT:' + } + TITULO3 = { + '2.0': 'N° Aprobación:', + '2.2': 'N° Aprobación:', + '3.0': 'Folio Fiscal:', + '3.2': 'Folio Fiscal:' + } + SPACE = 8 + LIMIT_MARGIN = 260 + DECIMALES = 2 + + def __init__(self, path_xml, status_callback=print): + super().__init__(format='Letter') + self.status = status_callback + try: + self.xml = ET.parse(path_xml).getroot() + self.status('Generando: {}'.format(path_xml)) + except Exception as e: + self.xml = None + self.status('Error al parsear: {}'.format(path_xml)) + self.status(str(e)) + self.compress = False + self.error = '' + self.set_auto_page_break(True, margin=15) + self.SetRightMargin = 15 + self.SetTopMargin = 5 + self.set_draw_color(50, 50, 50) + self.set_title('Factura Libre') + self.set_author('www.facturalibre.net') + self.line_width = 0.1 + self.alias_nb_pages() + self.pos_size = {'x': 0, 'y': 0, 'w': 0, 'h': 0} + self.y_detalle = 80 + self.data = {} + self.elements = {} + decimales = self.DECIMALES + if self.xml: + self.version = self.xml.attrib['version'] + self.cadena = self._get_cadena(path_xml, self.version) + self._parse_csv() + decimales = len(self.xml.attrib['total'].split('.')[1]) + self.currency = '{0:,.%sf}' % decimales + self.monedas = { + 'peso': ('peso', '$', 'm.n.'), + 'pesos': ('peso', '$', 'm.n.'), + 'mxn': ('peso', '$', 'm.n.'), + 'mxp': ('peso', '$', 'm.n.'), + 'euro': ('euro', '€', '€'), + 'euros': ('euro', '€', '€'), + 'dolar': ('dolar', '$', 'usd'), + 'dolares': ('dolar', '$', 'usd'), + 'usd': ('dolar', '$', 'usd'), + } + self.timbre = '' + + def make_pdf(self): + self.add_page() + self._set_detalle(self.G.PREFIX[self.version]) + self._set_totales(self.G.PREFIX[self.version]) + self._set_comprobante2(self.G.PREFIX[self.version]) + return + + def header(self): + self._set_emisor(self.G.PREFIX[self.version]) + self._set_receptor(self.G.PREFIX[self.version]) + self._set_comprobante(self.G.PREFIX[self.version]) + self._set_encabezados_detalle() + pass + + def footer(self): + self.set_y(-15) + self.set_font('Helvetica', '', 8) + self.set_text_color(*self._rgb(0)) + self.cell(0, 10, 'Página %s de {nb}' % self.page_no(), 0, 0, 'R') + self.set_x(10) + s = 'Factura elaborada con software libre ' + self.cell(0, 10, s, 0, 0, 'L') + self.set_x(10 + self.get_string_width(s)) + self.set_text_color(*self._rgb(6291456)) + self.cell(0, 10, 'www.facturalibre.net', 0, 0, 'L', + link='www.facturalibre.net') + + def _verify_margin(self, h=0): + mb = self.y + h + if mb > self.LIMIT_MARGIN: + self.add_page() + self.set_y(self.y_detalle) + return + + def _set_leyendas(self, pre): + com = self.xml.find('%sComplemento' % pre) + if com is None: + return + nodo = com.find('%sLeyendasFiscales' % self.G.PREFIX['LEYENDAS']) + if nodo is None: + return + for leyenda in list(nodo): + if 'disposicionFiscal' in leyenda.attrib: + self.elements['fiscal1_titulo']['y'] = self.y + self.elements['fiscal1']['y'] = self.y + self._write_text('fiscal1_titulo') + self._write_text('fiscal1', leyenda.attrib['disposicionFiscal']) + if 'norma' in leyenda.attrib: + self.elements['fiscal2_titulo']['y'] = self.y + self.elements['fiscal2']['y'] = self.y + self._write_text('fiscal2_titulo') + self._write_text('fiscal2', leyenda.attrib['norma']) + self.elements['fiscal_leyenda']['y'] = self.y + 5 + self._write_text('fiscal_leyenda', leyenda.attrib['textoLeyenda']) + self.ln(self.SPACE) + return + + def _set_donataria(self, pre): + com = self.xml.find('%sComplemento' % pre) + if com is None: return + nodo = com.find('%sDonatarias' % self.G.PREFIX['DONATARIA']) + if nodo is None: return + self.elements['dona_aut_titulo']['y'] = self.y + self.elements['dona_aut']['y'] = self.y + self._write_text('dona_aut_titulo') + self._write_text('dona_aut', nodo.attrib['noAutorizacion']) + self.elements['dona_fecha_titulo']['y'] = self.y + self.elements['dona_fecha']['y'] = self.y + self._write_text('dona_fecha_titulo') + self._write_text('dona_fecha', nodo.attrib['fechaAutorizacion']) + self.elements['dona_leyenda']['y'] = self.y + 5 + self._write_text('dona_leyenda', nodo.attrib['leyenda']) + self.ln(self.SPACE) + return + + def _set_comprobante2(self, pre): + fields = ( + ('Moneda', 'Moneda'), + ('TipoCambio', u'Tipo de cambio'), + ('formaDePago', u'Forma de pago'), + ('condicionesDePago', u'Condiciones de pago'), + ('metodoDePago', u'Método de pago'), + ('NumCtaPago', u'Cuenta de pago'), + ) + self._verify_margin(20) + self._set_donataria(pre) + self._verify_margin(20) + self._set_leyendas(pre) + self._verify_margin(20) + for f in fields: + if f[0] in self.xml.attrib: + self._write_otros(f[1], self.xml.attrib[f[0]]) + self.ln(self.SPACE) + if self.timbre: + qr_data = '?re=%s&rr=%s&tt=%s&id=%s' % ( + self.data['emisor_rfc'], + self.data['receptor_rfc'], + '%017.06f' % float(self.xml.attrib['total']), + self.timbre['UUID'] + ) + path = self._get_cbb(qr_data) + if os.path.exists(path): + self._verify_margin(self.elements['qr_cbb']['h']) + self.image( + path, + self.elements['qr_cbb']['x'], + self.y, + self.elements['qr_cbb']['w'], + self.elements['qr_cbb']['h'] + ) + os.unlink(path) + sello_emisor = self.timbre['selloCFD'] + sello_sat = self.timbre['selloSAT'] + self.elements['sello_cfd_titulo']['y'] = self.y + self.elements['sello_cfd_titulo']['text'] += 'I' + self._write_text('sello_cfd_titulo') + self.elements['sello_cfd']['y'] = self.y + 4 + self._write_text('sello_cfd', sello_emisor) + self.elements['sello_sat_titulo']['y'] = self.y + 1 + self._write_text('sello_sat_titulo') + self.elements['sello_sat']['y'] = self.y + 4 + self._write_text('sello_sat', sello_sat) + self.elements['fecha_titulo']['y'] = self.y + 1 + self._write_text('fecha_titulo') + self.elements['fecha']['y'] = self.y + self._write_text('fecha', self.timbre['FechaTimbrado']) + self.elements['leyenda']['text'] += 'I' + else: + sello_emisor = self.xml.attrib['sello'] + self.elements['sello_cfd_titulo']['x'] = 10 + self.elements['sello_cfd_titulo']['y'] = self.y + self._write_text('sello_cfd_titulo') + self.elements['sello_cfd']['x'] = 10 + self.elements['sello_cfd']['y'] = self.y + 4 + self.elements['sello_cfd']['w'] = 195 + self.elements['sello_cfd']['multiline'] = 0 + self._write_text('sello_cfd', sello_emisor) + self.elements['cadena_titulo']['text'] = 'Cadena original del CFD' + self._verify_margin(10) + self.elements['cadena_titulo']['y'] = self.y + 5 + self._write_text('cadena_titulo') + self.elements['cadena']['y'] = self.y + 4 + self._write_text('cadena', self.cadena) + #~ self._verify_margin(10) + self.elements['leyenda']['y'] = self.y + 5 + self._write_text('leyenda') + return + + def _get_cbb(self, data): + scale = 10 + f = tempfile.NamedTemporaryFile(suffix='.png', delete=False) + path = f.name + code = pyqrcode.QRCode(data, mode='binary') + code.png(path, scale) + return path + + + def _set_totales(self, pre): + moneda = ('peso', '$', 'm.n.') + if 'Moneda' in self.xml.attrib: + if self.xml.attrib['Moneda'].lower() in self.monedas: + moneda = self.monedas[self.xml.attrib['Moneda'].lower()] + else: + moneda = (self.xml.attrib['Moneda'], '', '') + importe = '%s %s' % ( + moneda[1], + self.currency.format(float(self.xml.attrib['subTotal'])) + ) + self._verify_margin(20) + self._write_importe('Subtotal', importe) + + if 'descuento' in self.xml.attrib: + if 'motivoDescuento' in self.xml.attrib: + self.elements['motivo_descuento']['y'] = self.y + self.elements['motivo_titulo']['y'] = self.y + self._write_text('motivo_titulo') + self._write_text( + 'motivo_descuento', self.xml.attrib['motivoDescuento']) + importe = '%s %s' % ( + moneda[1], + self.currency.format(float(self.xml.attrib['descuento'])) + ) + self._write_importe('Descuento', importe) + + imp = self.xml.find('%sImpuestos' % pre) + if imp is not None: + nodo = imp.find('%sTraslados' % pre) + if nodo is not None: + for n in list(nodo): + title = '%s %s %%' % (n.attrib['impuesto'], n.attrib['tasa']) + importe = '%s %s' % ( + moneda[1], + self.currency.format(float(n.attrib['importe'])) + ) + self._write_importe(title, importe) + nodo = imp.find('%sRetenciones' % pre) + if nodo is not None: + for n in list(nodo): + title = u'Retención %s' % n.attrib['impuesto'] + importe = '%s %s' % ( + moneda[1], + self.currency.format(float(n.attrib['importe'])) + ) + self._write_importe(title, importe) + + com = self.xml.find('%sComplemento' % pre) + if com is not None: + otros = com.find('%sImpuestosLocales' % self.G.PREFIX['IMP_LOCAL']) + if otros is not None: + for otro in list(otros): + if otro.tag == '%sRetencionesLocales' % self.G.PREFIX['IMP_LOCAL']: + name = 'ImpLocRetenido' + tasa = 'TasadeRetencion' + else: + name = 'ImpLocTrasladado' + tasa = 'TasadeTraslado' + title = u'%s %s %%' % (otro.attrib[name], otro.attrib[tasa]) + importe = '%s %s' % ( + moneda[1], + self.currency.format(float(otro.attrib['Importe'])) + ) + self._write_importe(title, importe) + + if 'totalImpuestosTrasladados' in imp.attrib: + importe = '%s %s' % ( + moneda[1], + self.currency.format(float(imp.attrib['totalImpuestosTrasladados'])) + ) + self.elements['imp_tras_titulo']['y'] = self.y + self.elements['imp_trasladado']['y'] = self.y + self._write_text('imp_tras_titulo') + self._write_text('imp_trasladado', importe) + if 'totalImpuestosRetenidos' in imp.attrib: + importe = '%s %s' % ( + moneda[1], + self.currency.format(float(imp.attrib['totalImpuestosRetenidos'])) + ) + self.elements['imp_rete_titulo']['y'] = self.y + self.elements['imp_retenido']['y'] = self.y + self._write_text('imp_rete_titulo') + self._write_text('imp_retenido', importe) + + total = float(self.xml.attrib['total']) + importe = '%s %s' % ( + moneda[1], + self.currency.format(total) + ) + self._write_importe('Total', importe) + self.ln() + letras = '-(%s/100 %s)-' % ( + NumerosLetras().to_letters(total, moneda[0]), + moneda[2] + ) + self._verify_margin(20) + self.elements['en_letras']['y'] = self.y + self._write_text('en_letras', letras.upper()) + self.ln(self.SPACE) + return + + def _write_otros(self, title, value): + self.elements['otros_titulo']['text'] = title + self.elements['otros_titulo']['y'] = self.y + self.elements['otros']['y'] = self.y + self._write_text('otros_titulo') + self._write_text('otros', value) + self.y += 3 + return + + def _write_importe(self, title, importe): + self.elements['subtotal_titulo']['text'] = title + self.elements['subtotal_titulo']['y'] = self.y + self.elements['subtotal']['y'] = self.y + self._write_text('subtotal_titulo') + self._write_text('subtotal', importe) + self.y += 4.5 + return + + def _set_detalle(self, pre): + conceptos = self.xml.find('%sConceptos' % pre) + for c in list(conceptos): + clave = '' + if 'noIdentificacion' in c.attrib: + clave = c.attrib['noIdentificacion'] + self._write_text('clave', clave) + unidad = 'No aplica' + if 'unidad' in c.attrib: + unidad = c.attrib['unidad'] + self._write_text('unidad', unidad) + importe = self.currency.format(float(c.attrib['cantidad'])) + self._write_text('cantidad', importe) + importe = self.currency.format(float(c.attrib['valorUnitario'])) + if c.attrib['valorUnitario'].startswith('-'): + self.elements['pu']['foreground'] = self.COLOR_RED + else: + self.elements['pu']['foreground'] = 0 + self._write_text('pu', importe) + importe = self.currency.format(float(c.attrib['importe'])) + if c.attrib['importe'].startswith('-'): + self.elements['importe']['foreground'] = self.COLOR_RED + else: + self.elements['importe']['foreground'] = 0 + self._write_text('importe', importe) + page = self.page_no() + descripcion = self._get_descripcion(c, pre) + self._write_text('descripcion', descripcion) + if self.page_no() > page: + new_y = self.y_detalle + else: + new_y = self.y + self.elements['clave']['y'] = new_y + self.elements['descripcion']['y'] = new_y + self.elements['unidad']['y'] = new_y + self.elements['cantidad']['y'] = new_y + self.elements['pu']['y'] = new_y + self.elements['importe']['y'] = new_y + self.line(10, self.y, 205, self.y) + self.ln() + return + + def _get_descripcion(self, nodo, pre): + s = nodo.attrib['descripcion'] + for n in list(nodo): + if n.tag == '%sInformacionAduanera' % pre: + s += u'\nAduana: %s\nFecha: %s Número: %s' % ( + n.attrib['aduana'], + n.attrib['fecha'], + n.attrib['numero'] + ) + elif n.tag == '%sCuentaPredial' % pre: + s += u'\n\nCuenta Predial Número: %s' % n.attrib['numero'] + elif n.tag == '%sParte' % pre: + serie = '' + if 'noIdentificacion' in n.attrib: + serie = n.attrib['noIdentificacion'] + s += u'\n\n Cantidad: %s Serie: %s\n %s' % ( + n.attrib['cantidad'], + serie, + n.attrib['descripcion'] + ) + for n2 in list(n): + if n2.tag == '%sInformacionAduanera' % pre: + s += u'\n Aduana: %s\n Fecha: %s Número: %s' % ( + n2.attrib['aduana'], + n2.attrib['fecha'], + n2.attrib['numero'] + ) + info = nodo.find('%sComplementoConcepto' % pre) + if info is None: + return s + info = info.find('{}instEducativas'.format(self.G.PREFIX['IEDU'])) + if info is not None: + s1 = '' + if 'nombreAlumno' in info.attrib: + s += '\n\nAlumno: %s' % info.attrib['nombreAlumno'] + if 'CURP' in info.attrib: + s += '\nCURP: %s' % info.attrib['CURP'] + if 'nivelEducativo' in info.attrib: + s1 = '\nAcuerdo de incorporación ante la SEP %s' % info.attrib['nivelEducativo'] + if 'autRVOE' in info.attrib: + if s1: + s1 = '%s %s' % (s1, info.attrib['autRVOE']) + else: + s1 = '\nAcuerdo de incorporación ante la SEP No: %s' % info.attrib['autRVOE'] + s += s1 + if 'rfcPago' in info.attrib: + s += '\nRFC de pago: %s' % info.attrib['rfcPago'] + return s + + def _set_encabezados_detalle(self): + self._write_text('clave_titulo') + self._write_text('descripcion_titulo') + self._write_text('unidad_titulo') + self._write_text('cantidad_titulo') + self._write_text('pu_titulo') + self._write_text('importe_titulo') + self.ln() + return + + def _set_comprobante(self, pre): + if 'LugarExpedicion' in self.xml.attrib: + lugar = '%s, ' % self.xml.attrib['LugarExpedicion'] + else: + lugar = self.data['expedicion'] + fecha = self.xml.attrib['fecha'].split('T') + date = datetime.strptime(fecha[0], '%Y-%m-%d') + lugar += date.strftime('%A, %d de %B del %Y') # .decode('utf-8') + self._write_text('cfdi_fecha', lugar) + self._write_text('cfdi_hora', fecha[1]) + self._write_text('cfdi_titulo1') + self._write_text('cfdi_titulo2', self.TITULO2[self.version]) + self._write_text('cfdi_titulo3', self.TITULO3[self.version]) + self._write_text('cfdi_titulo4') + self._write_text('cfdi_titulo5') + self._write_text('cfdi_titulo', self.TITULO1[self.version]) + self._write_text('cfdi_regimen', self.data['regimen']) + self.timbre = self._get_timbre(self.G.PREFIX[self.version]) + csdsat = '' + uuid = '' + if self.timbre: + csdsat = self.timbre['noCertificadoSAT'] + uuid = self.timbre['UUID'].upper() + else: + if 'anoAprobacion' in self.xml.attrib: + csdsat = self.xml.attrib['anoAprobacion'] + if 'noAprobacion' in self.xml.attrib: + uuid = self.xml.attrib['noAprobacion'] + folio = '' + if 'serie' in self.xml.attrib: + folio += self.xml.attrib['serie'] + if 'folio' in self.xml.attrib: + if folio: + folio += '-%s' % self.xml.attrib['folio'] + else: + folio = self.xml.attrib['folio'] + self._write_text('cfdi_csd', self.xml.attrib['noCertificado']) + self._write_text('cfdi_csdsat', csdsat) + self._write_text('cfdi_uuid', uuid) + self._write_text('cfdi_tipo', self.xml.attrib['tipoDeComprobante'].upper()) + self._write_text('cfdi_folio', folio) + return + + def _set_receptor(self, pre): + self._write_text('receptor_titulo') + receptor = self.xml.find('%sReceptor' % pre) + name_receptor = 'Sin nombre' + if 'nombre' in receptor.attrib: + name_receptor = receptor.attrib['nombre'] + self._write_text('receptor_nombre', name_receptor) + self._write_text('receptor_rfc', receptor.attrib['rfc']) + self.data['receptor_rfc'] = receptor.attrib['rfc'] + dir_fiscal = receptor.find('%sDomicilio' % pre) + domicilio1 = '' + if dir_fiscal is not None: + if 'calle' in dir_fiscal.attrib: + domicilio1 += '%s ' % dir_fiscal.attrib['calle'] + if 'noExterior' in dir_fiscal.attrib: + domicilio1 += '%s ' % dir_fiscal.attrib['noExterior'] + if 'noInterior' in dir_fiscal.attrib: + domicilio1 += '%s ' % dir_fiscal.attrib['noInterior'] + domicilio2 = '' + if 'colonia' in dir_fiscal.attrib: + if dir_fiscal.attrib['colonia'].strip().lower().startswith('col.'): + domicilio2 += '%s, ' % dir_fiscal.attrib['colonia'] + else: + domicilio2 += 'Col. %s, ' % dir_fiscal.attrib['colonia'] + if 'codigoPostal' in dir_fiscal.attrib: + domicilio2 += 'C.P. %s ' % dir_fiscal.attrib['codigoPostal'] + domicilio3 ='' + if 'municipio' in dir_fiscal.attrib: + domicilio3 += '%s, ' % dir_fiscal.attrib['municipio'] + if 'estado' in dir_fiscal.attrib: + domicilio3 += '%s, ' % dir_fiscal.attrib['estado'] + if 'pais' in dir_fiscal.attrib: + domicilio3 += '%s ' % dir_fiscal.attrib['pais'] + domicilio4 = '' + if 'localidad' in dir_fiscal.attrib: + domicilio4 += 'Localidad: %s, ' % dir_fiscal.attrib['localidad'] + if 'referencia' in dir_fiscal.attrib: + domicilio4 += 'Referencia: %s' % dir_fiscal.attrib['referencia'] + self._write_text('receptor_direccion1', domicilio1) + self._write_text('receptor_direccion2', domicilio2) + self._write_text('receptor_direccion3', domicilio3) + self._write_text('receptor_direccion4', domicilio4) + return + + def _set_emisor(self, pre): + emisor = self.xml.find('%sEmisor' % pre) + dir_fiscal = emisor.find('%sExpedidoEn' % pre) + lugar = '' + if dir_fiscal is not None: + self.elements['emisor_logo']['x'] = 10 + self.elements['emisor_nombre']['x'] = 45 + self.elements['emisor_rfc']['x'] = 45 + self.elements['emisor_direccion1']['x'] = 45 + self.elements['emisor_direccion2']['x'] = 45 + self.elements['emisor_direccion3']['x'] = 45 + self.elements['emisor_direccion4']['x'] = 45 + self.elements['emisor_nombre']['w'] = 75 + self.elements['emisor_rfc']['w'] = 75 + self.elements['emisor_direccion1']['w'] = 75 + self.elements['emisor_direccion2']['w'] = 75 + self.elements['emisor_direccion3']['w'] = 75 + self.elements['emisor_direccion4']['w'] = 75 + domicilio1 = '' + if 'calle' in dir_fiscal.attrib: + domicilio1 += '%s ' % dir_fiscal.attrib['calle'] + if 'noExterior' in dir_fiscal.attrib: + domicilio1 += '%s ' % dir_fiscal.attrib['noExterior'] + if 'noInterior' in dir_fiscal.attrib: + domicilio1 += '%s ' % dir_fiscal.attrib['noInterior'] + domicilio2 = '' + if 'colonia' in dir_fiscal.attrib: + if dir_fiscal.attrib['colonia'].strip().lower().startswith('col.'): + domicilio2 += '%s, ' % dir_fiscal.attrib['colonia'] + else: + domicilio2 += 'Col. %s, ' % dir_fiscal.attrib['colonia'] + if 'codigoPostal' in dir_fiscal.attrib: + domicilio2 += 'C.P. %s ' % dir_fiscal.attrib['codigoPostal'] + domicilio3 ='' + if 'municipio' in dir_fiscal.attrib: + domicilio3 += '%s, ' % dir_fiscal.attrib['municipio'] + lugar += '%s, ' % dir_fiscal.attrib['municipio'] + if 'estado' in dir_fiscal.attrib: + domicilio3 += '%s, ' % dir_fiscal.attrib['estado'] + lugar += '%s, ' % dir_fiscal.attrib['estado'] + if 'pais' in dir_fiscal.attrib: + domicilio3 += '%s ' % dir_fiscal.attrib['pais'] + domicilio4 = '' + if 'localidad' in dir_fiscal.attrib: + domicilio4 += '%s, ' % dir_fiscal.attrib['localidad'] + if 'referencia' in dir_fiscal.attrib: + domicilio4 += '%s' % dir_fiscal.attrib['referencia'] + self._write_text('expedido_titulo') + self._write_text('expedido_direccion1', domicilio1) + self._write_text('expedido_direccion2', domicilio2) + self._write_text('expedido_direccion3', domicilio3) + self._write_text('expedido_direccion4', domicilio4) + + path_logo = 'logos/{}.png'.format(emisor.attrib['rfc'].lower()) + self.data['emisor_rfc'] = emisor.attrib['rfc'] + if os.path.exists(path_logo): + self.image( + path_logo, + self.elements['emisor_logo']['x'], + self.elements['emisor_logo']['y'], + self.elements['emisor_logo']['w'], + self.elements['emisor_logo']['h'] + ) + regimen = emisor.find('%sRegimenFiscal' % pre) + if regimen is None: + regimen = '' + else: + regimen = regimen.attrib['Regimen'] + dir_fiscal = emisor.find('%sDomicilioFiscal' % pre) + lugar2 = '' + domicilio1 = '' + domicilio2 = '' + domicilio3 = '' + domicilio4 = '' + if not dir_fiscal is None: + if 'calle' in dir_fiscal.attrib: + domicilio1 += '%s ' % dir_fiscal.attrib['calle'] + if 'noExterior' in dir_fiscal.attrib: + domicilio1 += '%s ' % dir_fiscal.attrib['noExterior'] + if 'noInterior' in dir_fiscal.attrib: + domicilio1 += '%s ' % dir_fiscal.attrib['noInterior'] + + if 'colonia' in dir_fiscal.attrib: + if dir_fiscal.attrib['colonia'].strip().lower().startswith('col.'): + domicilio2 += '%s, ' % dir_fiscal.attrib['colonia'] + else: + domicilio2 += 'Col. %s, ' % dir_fiscal.attrib['colonia'] + if 'codigoPostal' in dir_fiscal.attrib: + domicilio2 += 'C.P. %s ' % dir_fiscal.attrib['codigoPostal'] + + if 'municipio' in dir_fiscal.attrib: + domicilio3 += '%s, ' % dir_fiscal.attrib['municipio'] + lugar2 += '%s, ' % dir_fiscal.attrib['municipio'] + if 'estado' in dir_fiscal.attrib: + domicilio3 += '%s, ' % dir_fiscal.attrib['estado'] + lugar2 += '%s, ' % dir_fiscal.attrib['estado'] + if 'pais' in dir_fiscal.attrib: + domicilio3 += '%s ' % dir_fiscal.attrib['pais'] + + if 'localidad' in dir_fiscal.attrib: + domicilio4 += '%s, ' % dir_fiscal.attrib['localidad'] + if 'referencia' in dir_fiscal.attrib: + domicilio4 += '%s' % dir_fiscal.attrib['referencia'] + name_emisor = 'Sin nombre' + if 'nombre' in emisor.attrib: + name_emisor = emisor.attrib['nombre'] + self._write_text('emisor_nombre', name_emisor) + self._write_text('emisor_rfc', emisor.attrib['rfc']) + self._write_text('emisor_direccion1', domicilio1) + self._write_text('emisor_direccion2', domicilio2) + self._write_text('emisor_direccion3', domicilio3) + self._write_text('emisor_direccion4', domicilio4) + if not lugar: + lugar = lugar2 + self.data['expedicion'] = lugar + self.data['regimen'] = regimen + return + + def _rgb(self, col): + return (col // 65536), (col // 256 % 256), (col % 256) + + def _color(self, r, g, b): + return int('%02x%02x%02x' % (r, g, b), 16) + + def _write_text(self, k, s=''): + if not k in self.elements: return + self.pos_size['x'] = self.elements[k]['x'] + self.pos_size['y'] = self.elements[k]['y'] + self.pos_size['w'] = self.elements[k]['w'] + self.pos_size['h'] = self.elements[k]['h'] + self.set_font( + self.elements[k]['font'], + self.elements[k]['style'], + self.elements[k]['size'] + ) + self.set_text_color(*self._rgb(self.elements[k]['foreground'])) + self.set_fill_color(*self._rgb(self.elements[k]['background'])) + self.set_xy(self.pos_size['x'], self.pos_size['y']) + if self.elements[k]['border'] == '0' or \ + self.elements[k]['border'] == '1': + border = eval(self.elements[k]['border']) + else: + border = self.elements[k]['border'] + if not s: + s = self.elements[k]['text'] + if self.elements[k]['multiline']: + self.multi_cell( + self.pos_size['w'], + self.pos_size['h'], + s, + border, + self.elements[k]['align'], + bool(self.elements[k]['background']) + ) + else: + self._ajust_text(s, self.elements[k]['size']) + self.cell( + self.pos_size['w'], + self.pos_size['h'], + s, + border, + 0, + self.elements[k]['align'], + bool(self.elements[k]['background']) + ) + return + + def _ajust_text(self, s, size): + if not isinstance(s, str): + s = str(s) + s = s.encode('ascii', 'replace') + while True: + if self.get_string_width(s) < (self.pos_size['w']-0.8): + break + else: + size -= 1 + self.set_font_size(size) + return + + def _get_timbre(self, pre): + com = self.xml.find('%sComplemento' % pre) + if com is None: return {} + timbre = com.find('%sTimbreFiscalDigital' % self.G.PREFIX['TIMBRE']) + if timbre is None: + return {} + else: + return timbre.attrib + + def _get_cadena(self, path_xml, version): + #~ return self.G.CADENA.format(**self.timbre) + #~ from lxml import etree + + #~ file_xslt = self.G.PATHS['XSLT_CADENA'].format(version) + #~ styledoc = etree.parse(file_xslt) + #~ transform = etree.XSLT(styledoc) + #~ parser = etree.XMLParser(ns_clean=True, recover=True, encoding='utf-8') + #~ doc = etree.fromstring(xml.encode('utf-8'), parser=parser) + #~ result = str(transform(doc)) + return '' + + def _parse_csv(self, emisor_rfc=''): + "Parse template format csv file and create elements dict" + keys = ('x', 'y', 'w', 'h', 'font', 'size', 'style', 'foreground', + 'background', 'align', 'priority', 'multiline', 'border', 'text') + self.elements = {} + path_template = '{}/{}.csv'.format( + self.G.PATHS['TEMPLATE'], + emisor_rfc.lower() + ) + if not os.path.exists(path_template): + path_template = '{}/default.csv'.format(self.G.PATHS['TEMPLATE']) + reader = csv.reader(open(path_template, 'r'), delimiter=',') + for row in reader: + new_list = [] + for v in row[1:]: + if v == (''): + new_list.append('') + elif v.startswith("'"): + new_list.append(v[1:-1]) + else: + new_list.append(eval(v.strip())) + self.elements[row[0][1:-1]] = dict(zip(keys, new_list)) + diff --git a/template/default.csv b/template/default.csv new file mode 100644 index 0000000..ce81a64 --- /dev/null +++ b/template/default.csv @@ -0,0 +1,77 @@ +'headers','x','y','w','h','font','size','style','foreground','background','align','priority','multiline','border', 'text' +'emisor_logo',30,5,35,30,'',0,'',0,0,'',0,0,'' +'emisor_nombre',85,5,100,4,'Helvetica',10,'B',6291456,14803425,'C',0,0,'0','' +'emisor_rfc',85,9,100,4,'Helvetica',10,'B',6291456,14803425,'C',0,0,'0','' +'emisor_direccion1',85,13,100,4,'Helvetica',9,'B',0,0,'C',0,0,'0','' +'emisor_direccion2',85,17,100,4,'Helvetica',9,'B',0,0,'C',0,0,'0','' +'emisor_direccion3',85,21,100,4,'Helvetica',9,'B',0,0,'C',0,0,'0','' +'emisor_direccion4',85,25,100,4,'Helvetica',9,'B',0,0,'C',0,0,'0','' +'expedido_titulo',130,5,75,4,'Helvetica',7,'B',6291456,14803425,'C',0,0,'0','Expedido En' +'expedido_direccion1',130,9,75,4,'Helvetica',8,'B',0,0,'C',0,0,'0','' +'expedido_direccion2',130,13,75,4,'Helvetica',8,'B',0,0,'C',0,0,'0','' +'expedido_direccion3',130,17,75,4,'Helvetica',8,'B',0,0,'C',0,0,'0','' +'expedido_direccion4',130,21,75,4,'Helvetica',8,'B',0,0,'C',0,0,'0','' +'receptor_titulo',10,40,100,4,'Helvetica',7,'B',6291456,14803425,'C',0,0,'0','Receptor' +'receptor_nombre',15,45,95,4,'Helvetica',10,'B',6291456,0,'L',0,0,'0','' +'receptor_rfc',15,49,95,4,'Helvetica',10,'B',6291456,0,'L',0,0,'0','' +'receptor_direccion1',15,53,100,4,'Helvetica',9,'B',0,0,'L',0,0,'0','' +'receptor_direccion2',15,57,100,4,'Helvetica',9,'B',0,0,'L',0,0,'0','' +'receptor_direccion3',15,61,100,4,'Helvetica',9,'B',0,0,'L',0,0,'0','' +'receptor_direccion4',15,65,100,4,'Helvetica',8,'B',0,0,'L',0,0,'0','' +'cfdi_titulo',120,40,85,4,'Helvetica',7,'B',6291456,14803425,'C',0,0,'0','' +'cfdi_fecha',10,35,170,4,'Helvetica',8,'',0,0,'R',0,0,'0','' +'cfdi_hora',180,35,20,4,'Helvetica',6,'B',0,0,'R',0,0,'0','' +'cfdi_titulo1',120,44,30,4,'Helvetica',7,'B',6291456,14803425,'R',0,0,'0','Serie CSD Emisor' +'cfdi_titulo2',120,48,30,4,'Helvetica',7,'B',6291456,14803425,'R',0,0,'0','' +'cfdi_titulo3',120,52,30,4,'Helvetica',7,'B',6291456,14803425,'R',0,0,'0','' +'cfdi_titulo4',120,56,30,4,'Helvetica',7,'B',6291456,14803425,'R',0,0,'0','Tipo de Comprobante' +'cfdi_titulo5',120,60,30,4,'Helvetica',7,'B',6291456,14803425,'R',0,0,'0','Folio' +'cfdi_regimen',120,64,85,4,'Helvetica',7,'B',6291456,14803425,'C',0,0,'0','' +'cfdi_csd',150,44,55,4,'Helvetica',7,'',0,0,'C',0,0,'0','' +'cfdi_csdsat',150,48,55,4,'Helvetica',7,'',0,0,'C',0,0,'0','' +'cfdi_uuid',150,52,55,4,'Helvetica',7,'',0,0,'C',0,0,'0','' +'cfdi_tipo',150,56,55,4,'Helvetica',7,'',0,0,'C',0,0,'0','' +'cfdi_folio',150,60,55,4,'Helvetica',12,'B',16711680,0,'C',0,0,'0','' +'clave_titulo',10,70,20,3,'Helvetica',7,'B',6291456,14803425,'C',0,0,'R','Clave' +'descripcion_titulo',30,70,85,3,'Helvetica',7,'B',6291456,14803425,'C',0,0,'R','Descripcion' +'unidad_titulo',115,70,15,3,'Helvetica',7,'B',6291456,14803425,'C',0,0,'R','Unidad' +'cantidad_titulo',130,70,20,3,'Helvetica',7,'B',6291456,14803425,'C',0,0,'R','Cantidad' +'pu_titulo',150,70,25,3,'Helvetica',7,'B',6291456,14803425,'C',0,0,'R','Valor Unitario' +'importe_titulo',175,70,30,3,'Helvetica',7,'B',6291456,14803425,'C',0,0,'','Importe' +'clave',10,73,20,3,'Helvetica',7,'',0,0,'C',0,0,'T','Sin' +'descripcion',30,73,85,3,'Helvetica',7,'',0,0,'J',0,1,'T','' +'unidad',115,73,15,3,'Helvetica',7,'',0,0,'C',0,0,'T','' +'cantidad',130,73,20,3,'Helvetica',7,'',0,0,'R',0,0,'T','' +'pu',150,73,25,3,'Helvetica',7,'',0,0,'R',0,0,'T','' +'importe',175,73,30,3,'Helvetica',7,'',0,0,'R',0,0,'T','' +'subtotal_titulo',150,0,25,4,'Helvetica',8,'B',6291456,14803425,'R',0,0,'0','Subtotal' +'subtotal',175,0,30,4,'Helvetica',8,'B',0,0,'R',0,0,'0','' +'motivo_titulo',10,0,40,4,'Helvetica',8,'B',6291456,14803425,'R',0,0,'0','Motivo del descuento' +'motivo_descuento',50,0,95,4,'Helvetica',8,'',0,0,'L',0,0,'0','' +'imp_tras_titulo',10,0,40,4,'Helvetica',8,'B',6291456,14803425,'R',0,0,'0','Total impuestos trasladados' +'imp_rete_titulo',80,0,40,4,'Helvetica',8,'B',6291456,14803425,'R',0,0,'0','Total impuestos retenidos' +'imp_trasladado',50,0,25,4,'Helvetica',8,'B',0,0,'R',0,0,'0','' +'imp_retenido',120,0,25,4,'Helvetica',8,'B',0,0,'R',0,0,'0','' +'en_letras',10,0,195,4,'Helvetica',7,'B',0,0,'C',0,0,'0','' +'otros_titulo',10,0,25,3,'Helvetica',7,'B',6291456,14803425,'R',0,0,'0','' +'otros',35,0,40,3,'Helvetica',7,'',0,0,'L',0,0,'0','' +'dona_aut_titulo',10,0,40,4,'Helvetica',8,'B',6291456,14803425,'R',0,0,'0','No. Autorizacion' +'dona_fecha_titulo',80,0,40,4,'Helvetica',8,'B',6291456,14803425,'R',0,0,'0','Fecha de Autorizacion' +'dona_aut',50,0,25,4,'Helvetica',8,'B',0,0,'L',0,0,'0','' +'dona_fecha',120,0,25,4,'Helvetica',8,'B',0,0,'L',0,0,'0','' +'dona_leyenda',10,0,195,4,'Helvetica',6,'',6291456,14803425,'J',0,0,'0','' +'fiscal1_titulo',10,0,40,4,'Helvetica',8,'B',6291456,14803425,'R',0,0,'0','Disposicion Fiscal' +'fiscal2_titulo',80,0,40,4,'Helvetica',8,'B',6291456,14803425,'R',0,0,'0','Norma' +'fiscal1',50,0,25,4,'Helvetica',8,'B',0,0,'C',0,0,'0','' +'fiscal2',120,0,85,4,'Helvetica',8,'B',0,0,'L',0,0,'0','' +'fiscal_leyenda',10,0,195,4,'Helvetica',6,'',6291456,14803425,'J',0,1,'0','' +'qr_cbb',10,0,30,30,'',0,'',0,0,'',0,0,'' +'sello_cfd_titulo',45,0,160,4,'Helvetica',6,'B',0,0,'L',0,0,'0','Sello Digital del CFD' +'sello_cfd',45,0,160,4,'Helvetica',6,'',6291456,14803425,'J',0,1,'0','' +'sello_sat_titulo',45,0,160,4,'Helvetica',6,'B',0,0,'L',0,0,'0','Sello del SAT' +'sello_sat',45,0,160,4,'Helvetica',6,'',6291456,14803425,'J',0,1,'0','' +'fecha_titulo',45,0,35,4,'Helvetica',6,'B',0,0,'L',0,0,'0','Fecha y hora de certificacion:' +'fecha',80,0,50,4,'Helvetica',6,'',0,0,'L',0,0,'0','' +'cadena_titulo',10,0,195,4,'Helvetica',6,'B',0,0,'L',0,0,'0','Cadena original del complemento de certificacion digital del SAT' +'cadena',10,0,195,4,'Helvetica',6,'',6291456,14803425,'J',0,1,'0','' +'leyenda',65.5,0,80,4,'Helvetica',6,'',6291456,14803425,'C',0,0,'0','Este documento es una representacion impresa de CFD' \ No newline at end of file diff --git a/values.py b/values.py index 690369a..a7b3224 100644 --- a/values.py +++ b/values.py @@ -37,6 +37,7 @@ class Global(object): 'XSLT_SELLO_SAT': os.path.join(CWD, 'bin', 'get_sello_sat.xslt'), 'XSLT_CADENA': os.path.join(CWD, 'bin', 'cfdi_{}.xslt'), 'XSLT_TIMBRE': os.path.join(CWD, 'bin', 'timbre_1.0.xslt'), + 'TEMPLATE': os.path.join(CWD, 'template'), } EXT_XML = '.xml' EXT_ODS = '.ods' @@ -147,6 +148,7 @@ class Global(object): 'IMP_LOCAL': '{http://www.sat.gob.mx/implocal}', 'IEDU': '{http://www.sat.gob.mx/iedu}', 'DONATARIA': '{http://www.sat.gob.mx/donat}', + 'LEYENDAS': '{http://www.sat.gob.mx/leyendasFiscales}', } page_init = 'https://cfdiau.sat.gob.mx/nidp/app/login?id=SATUPCFDiCon&' \ 'sid=0&option=credential&sid=0' @@ -215,4 +217,4 @@ class Global(object): if DEBUG: LOG = logging.getLogger('AdminCFDI_screen') else: - LOG = logging.getLogger('AdminCFDI') \ No newline at end of file + LOG = logging.getLogger('AdminCFDI') diff --git a/xml2pdf.py b/xml2pdf.py new file mode 100755 index 0000000..ec5a8c8 --- /dev/null +++ b/xml2pdf.py @@ -0,0 +1,41 @@ +import argparse + +from pyutil import CSVPDF +from pyutil import Util + +def process_command_line_arguments(): + parser = argparse.ArgumentParser( + description='Crea un PDF desde una plantilla CSV') + + help = 'Archivo XML origen.' + parser.add_argument('-a', '--archivo-xml', help=help, default='') + + help = 'Directorio origen.' + parser.add_argument('-o', '--directorio-origen', help=help, default='') + + args = parser.parse_args() + return args + + +def main(): + ext_pdf = '.pdf' + args = process_command_line_arguments() + util = Util() + + if args.archivo_xml and util.exists(args.archivo_xml): + path_pdf = util.replace_extension(args.archivo_xml, ext_pdf) + pdf = CSVPDF(args.archivo_xml) + if pdf.xml: + pdf.make_pdf() + pdf.output(path_pdf, 'F') + if args.directorio_origen: + files = util.get_files(args.directorio_origen) + for f in files: + path_pdf = util.replace_extension(f, ext_pdf) + pdf = CSVPDF(f) + if pdf.xml: + pdf.make_pdf() + pdf.output(path_pdf, 'F') + +if __name__ == '__main__': + main() From 25b3fb450f95c6d801ac15aef5fd9e7e180e3bc7 Mon Sep 17 00:00:00 2001 From: Mauricio Baeza Date: Wed, 25 Mar 2015 18:33:19 -0600 Subject: [PATCH 041/167] Se agrega la cadena original --- pyutil.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/pyutil.py b/pyutil.py index b3a40e6..e191c22 100644 --- a/pyutil.py +++ b/pyutil.py @@ -1976,7 +1976,7 @@ def __init__(self, path_xml, status_callback=print): decimales = self.DECIMALES if self.xml: self.version = self.xml.attrib['version'] - self.cadena = self._get_cadena(path_xml, self.version) + #~ self.cadena = self._get_cadena(path_xml) self._parse_csv() decimales = len(self.xml.attrib['total'].split('.')[1]) self.currency = '{0:,.%sf}' % decimales @@ -2135,7 +2135,7 @@ def _set_comprobante2(self, pre): self.elements['cadena_titulo']['y'] = self.y + 5 self._write_text('cadena_titulo') self.elements['cadena']['y'] = self.y + 4 - self._write_text('cadena', self.cadena) + self._write_text('cadena', self._get_cadena()) #~ self._verify_margin(10) self.elements['leyenda']['y'] = self.y + 5 self._write_text('leyenda') @@ -2149,7 +2149,6 @@ def _get_cbb(self, data): code.png(path, scale) return path - def _set_totales(self, pre): moneda = ('peso', '$', 'm.n.') if 'Moneda' in self.xml.attrib: @@ -2646,11 +2645,11 @@ def _get_timbre(self, pre): else: return timbre.attrib - def _get_cadena(self, path_xml, version): - #~ return self.G.CADENA.format(**self.timbre) + def _get_cadena(self): + return self.G.CADENA.format(**self.timbre) #~ from lxml import etree - #~ file_xslt = self.G.PATHS['XSLT_CADENA'].format(version) + #~ file_xslt = self.G.PATHS['CADENA'].format(self.version) #~ styledoc = etree.parse(file_xslt) #~ transform = etree.XSLT(styledoc) #~ parser = etree.XMLParser(ns_clean=True, recover=True, encoding='utf-8') From 97cbdc87246398448860e9e77331911bcab291e1 Mon Sep 17 00:00:00 2001 From: Sergio E Date: Sun, 8 Mar 2015 19:26:42 -0600 Subject: [PATCH 042/167] Agregando sertup.py --- setup.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 setup.py diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..3bf8457 --- /dev/null +++ b/setup.py @@ -0,0 +1,13 @@ +from distutils.core import setup + +setup(name='Admin-CFDI', + version='0.2.6', + description='Herramienta para administracion de CFDIs', + license='GPL 3.0', + author='Mauricio Baeza', + author_email='correopublico@mauriciobaeza.org', + url='https://facturalibre.net/servicios/', + py_modules=['pyutil', 'values', 'template'], + scripts=['admincfdi.py']) + +# vim: ts=4 et sw=4 From e48fc7a3f0baf74256dbdcc7d1eed8c767cfdd8d Mon Sep 17 00:00:00 2001 From: Sergio E Date: Sun, 8 Mar 2015 19:36:50 -0600 Subject: [PATCH 043/167] Poniendo cabeceras pylint 10/10 --- setup.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/setup.py b/setup.py index 3bf8457..5a7d4e9 100644 --- a/setup.py +++ b/setup.py @@ -1,3 +1,7 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +''' Setup for Admin CFDI ''' + from distutils.core import setup setup(name='Admin-CFDI', From 23bee0e31809bc40a4c8dbe3482c1a9a411d3a88 Mon Sep 17 00:00:00 2001 From: Sergio E Date: Sun, 8 Mar 2015 19:53:36 -0600 Subject: [PATCH 044/167] =?UTF-8?q?Instalaci=C3=B3n=20de=20dependencias?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- setup.py | 1 + 1 file changed, 1 insertion(+) diff --git a/setup.py b/setup.py index 5a7d4e9..934cd91 100644 --- a/setup.py +++ b/setup.py @@ -11,6 +11,7 @@ author='Mauricio Baeza', author_email='correopublico@mauriciobaeza.org', url='https://facturalibre.net/servicios/', + install_requires=['pygubu', 'selenium'], py_modules=['pyutil', 'values', 'template'], scripts=['admincfdi.py']) From 785638089241a3246f2ad8fb212b6f2c1c5a54f7 Mon Sep 17 00:00:00 2001 From: Sergio E Date: Sun, 8 Mar 2015 20:12:04 -0600 Subject: [PATCH 045/167] Mover pyqrcode y pysimplesoap a setup.py Ahora estos paquetes estan declarados como dependencias en setup.py por lo que pueder ser removidos de la raiz del proyecto --- pyqrcode/__init__.py | 322 ---- pyqrcode/builder.py | 1089 ----------- pyqrcode/png.py | 3838 ------------------------------------- pyqrcode/tables.py | 746 ------- pysimplesoap/__init__.py | 16 - pysimplesoap/client.py | 713 ------- pysimplesoap/helpers.py | 465 ----- pysimplesoap/server.py | 560 ------ pysimplesoap/simplexml.py | 481 ----- pysimplesoap/transport.py | 274 --- setup.py | 2 +- 11 files changed, 1 insertion(+), 8505 deletions(-) delete mode 100644 pyqrcode/__init__.py delete mode 100644 pyqrcode/builder.py delete mode 100644 pyqrcode/png.py delete mode 100644 pyqrcode/tables.py delete mode 100644 pysimplesoap/__init__.py delete mode 100644 pysimplesoap/client.py delete mode 100644 pysimplesoap/helpers.py delete mode 100644 pysimplesoap/server.py delete mode 100644 pysimplesoap/simplexml.py delete mode 100644 pysimplesoap/transport.py diff --git a/pyqrcode/__init__.py b/pyqrcode/__init__.py deleted file mode 100644 index 97978b8..0000000 --- a/pyqrcode/__init__.py +++ /dev/null @@ -1,322 +0,0 @@ -"""This module is used to create QR Codes. It is designed to be as simple and -as possible. It does this by using sane defaults and autodetection to make -creating a QR Code very simple. - -It is recommended that you use the :func:`pyqrcode.create` function to build the -QRCode object. This results in cleaner looking code. - -Examples: - >>> import pyqrcode - >>> import sys - >>> url = pyqrcode.create('http://uca.edu') - >>> url.svg(sys.stdout, scale=1) - >>> url.svg('uca.svg', scale=4) - >>> number = pyqrcode.create(123456789012345) - >>> number.png('big-number.png') -""" -import pyqrcode.tables -import pyqrcode.builder as builder - -def create(content, error='H', version=None, mode=None): - """When creating a QR code only the content to be encoded is required, - all the other properties of the code will be guessed based on the - contents given. This function will return a :class:`QRCode` object. - - Unless you are familiar with QR code's inner workings - it is recommended that you just specify the content and nothing else. - However, there are cases where you may want to specify the various - properties of the created code manually, this is what the other - parameters do. Below, you will find a lengthy explanation of what - each parameter is for. Note, the parameter names and values are taken - directly from the standards. You may need to familiarize yourself - with the terminology of QR codes for the names to make sense. - - The *error* parameter sets the error correction level of the code. There - are four levels defined by the standard. The first is level 'L' which - allows for 7% of the code to be corrected. Second, is level 'M' which - allows for 15% of the code to be corrected. Next, is level 'Q' which - is the most common choice for error correction, it allow 25% of the - code to be corrected. Finally, there is the highest level 'H' which - allows for 30% of the code to be corrected. There are several ways to - specify this parameter, you can use an upper or lower case letter, - a float corresponding to the percentage of correction, or a string - containing the percentage. See tables.modes for all the possible - values. By default this parameter is set to 'H' which is the highest - possible error correction, but it has the smallest available data - capacity. - - The *version* parameter specifies the size and data capacity of the - code. Versions are any integer between 1 and 40. Where version 1 is - the smallest QR code, and version 40 is the largest. If this parameter - is left unspecified, then the contents and error correction level will - be used to guess the smallest possible QR code version that the - content will fit inside of. You may want to specify this parameter - for consistency when generating several QR codes with varying amounts - of data. That way all of the generated codes would have the same size. - - The *mode* parameter specifies how the contents will be encoded. By - default, the best possible encoding for the contents is guessed. There - are four possible encoding methods. First, is 'numeric' which is - used to encode integer numbers. Next, is 'alphanumeric' which is - used to encode some ASCII characters. This mode uses only a limited - set of characters. Most problematic is that it can only use upper case - English characters, consequently, the content parameter will be - subjected to str.upper() before encoding. See tables.ascii_codes for - a complete list of available characters. We then have 'binary' encoding - which just encodes the bytes directly into the QR code (this encoding - is the least efficient). Finally, there is 'kanji' encoding (i.e. - Japanese characters), this encoding is unimplemented at this time. - """ - return QRCode(content, error, version, mode) - -class QRCode: - """This class represents a QR code. To use this class simply give the - constructor a string representing the data to be encoded, it will then - build a code in memory. You can then save it in various formats. Note, - codes can be written out as PNG files but this requires the PyPNG module. - You can find the PyPNG module at http://packages.python.org/pypng/. - - Examples: - >>> from pyqrcode import QRCode - >>> import sys - >>> url = QRCode('http://uca.edu') - >>> url.svg(sys.stdout, scale=1) - >>> url.svg('uca.svg', scale=4) - >>> number = QRCode(123456789012345) - >>> number.png('big-number.png') - - .. note:: - For what all of the parameters do, see the :func:`pyqrcode.create` - function. - """ - def __init__(self, content, error='H', version=None, mode=None): - - #Coerce the content into a string - self.data = str(content) - - #Check that the passed in error level is valid - try: - self.error = tables.error_level[str(error).upper()] - except: - raise ValueError('The error parameter is not one of ' - '"L", "M", "Q", or "H."') - - #Guess the mode of the code, this will also be used for - #error checking - guessed_content_type = self._detect_content_type() - - #Force a passed in mode to be lowercase - if mode: - mode = mode.lower() - - #Check that the mode parameter is compatible with the contents - if not mode: - #Use the guessed mode - self.mode = guessed_content_type - self.mode_num = tables.modes[self.mode] - elif guessed_content_type == 'binary' and \ - tables.modes[mode] != tables.modes['binary']: - #Binary is only guessed as a last resort, if the - #passed in mode is not binary the data won't encode - raise ValueError('The content provided cannot be encoded with ' - 'the mode {}, it can only be encoded as ' - 'binary.'.format(mode)) - elif tables.modes[mode] == tables.modes['numeric'] and \ - guessed_content_type != 'numeric': - #If numeric encoding is requested make sure the data can - #be encoded in that format - raise ValueError('The content cannot be encoded as numeric.') - else: - #The data should encode with the passed in mode - self.mode = mode - self.mode_num = tables.modes[self.mode] - - #Guess the "best" version - self.version = self._pick_best_fit() - - #If the user supplied a version, then check that it has - #sufficient data capacity for the contents passed in - if version: - if version >= self.version: - self.version = version - else: - raise ValueError('The data will not fit inside a version {} ' - 'code with the given encoding and error ' - 'level (the code must be at least a ' - 'version {}).'.format(version, self.version)) - - #Build the QR code - self.builder = builder.QRCodeBuilder(data=content, - version=self.version, - mode=self.mode, - error=self.error) - - #Save the code for easier reference - self.code = self.builder.code - - def __str__(self): - return repr(self) - - def __repr__(self): - return 'QRCode(content=\'{}\', error=\'{}\', version={}, mode=\'{}\')'.format( - self.data, self.error, self.version, self.mode) - - - def _detect_content_type(self): - """This method tries to auto-detect the type of the data. It first - tries to see if the data is a valid integer, in which case it returns - numeric. Next, it tests the data to see if it is 'alphanumeric.' QR - Codes use a special table with very limited range of ASCII characters. - The code's data is tested to make sure it fits inside this limited - range. If all else fails, the data is determined to be of type - 'binary.' - - Note, encoding 'kanji' is not yet implemented. - """ - #See if the data is an integer - try: - test = int(self.data) - return 'numeric' - except: - #Data is not numeric, this is not an error - pass - - #See if that data is alphanumeric based on the standards - #special ASCII table - valid_characters = tables.ascii_codes.keys() - if all(map(lambda x: x in valid_characters, self.data.upper())): - return 'alphanumeric' - - #All of the tests failed. The content can only be binary. - return 'binary' - - def _pick_best_fit(self): - """This method return the smallest possible QR code version number - that will fit the specified data with the given error level. - """ - for version in range(1,41): - #Get the maximum possible capacity - capacity = tables.data_capacity[version][self.error][self.mode_num] - - #Check the capacity - if (self.mode_num == tables.modes['binary'] and \ - capacity >= len(self.data.encode('latin1'))) or \ - capacity >= len(self.data): - return version - - raise ValueError('The data will not fit in any QR code version ' - 'with the given encoding and error level.') - - def get_png_size(self, scale): - """This is method helps users determine what *scale* to use when - creating a PNG of this QR code. It is meant mostly to be used in the - console to help the user determine the pixel size of the code - using various scales. - - This method will return an integer representing the width and height of - the QR code in pixels, as if it was drawn using the given *scale*. - Because QR codes are square, the number represents both dimensions. - - Example: - >>> code = pyqrcode.QRCode("I don't like spam!") - >>> print(code.get_png_size(1)) - 31 - >>> print(code.get_png_size(5)) - 155 - """ - return builder._get_png_size(self.version, scale) - - def png(self, file, scale=1, module_color=None, background=None): - """This method writes the QR code out as an PNG image. The resulting - PNG has a bit depth of 1. The file parameter is used to specify where - to write the image to. It can either be an writable stream or a - file path. - - .. note:: - This method depends on the pypng module to actually create the - PNG file. - - This method will write the given *file* out as a PNG file. The file - can be either a string file path, or a writable stream. - - The *scale* parameter sets how large to draw a single module. By - default one pixel is used to draw a single module. This may make the - code too small to be read efficiently. Increasing the scale will make - the code larger. Only integer scales are usable. This method will - attempt to coerce the parameter into an integer (e.g. 2.5 will become 2, - and '3' will become 3). - - The *module_color* parameter sets what color to use for the encoded - modules (the black part on most QR codes). The *background* parameter - sets what color to use for the background (the white part on most - QR codes). If either parameter is set, then both must be - set or a ValueError is raised. Colors should be specified as either - a list or a tuple of length 3 or 4. The components of the list must - be integers between 0 and 255. The first three member give the RGB - color. The fourth member gives the alpha component, where 0 is - transparent and 255 is opaque. Note, many color - combinations are unreadable by scanners, so be careful. - - - - Example: - >>> code = pyqrcode.create('Are you suggesting coconuts migrate?') - >>> code.png('swallow.png', scale=5) - >>> code.png('swallow.png', scale=5, - module_color=(0x66, 0x33, 0x0), #Dark brown - background=(0xff, 0xff, 0xff, 0x88)) #50% transparent white - """ - builder._png(self.code, self.version, file, scale, - module_color, background) - - def svg(self, file, scale=1, module_color='#000000', background=None): - """This method writes the QR code out as an SVG document. The - code is drawn by drawing only the modules corresponding to a 1. They - are drawn using a line, such that contiguous modules in a row - are drawn with a single line. - - The *file* parameter is used to specify where to write the document - to. It can either be a writable stream or a file path. - - The *scale* parameter sets how large to draw - a single module. By default one pixel is used to draw a single - module. This may make the code too small to be read efficiently. - Increasing the scale will make the code larger. Unlike the png() method, - this method will accept fractional scales (e.g. 2.5). - - Note, three things are done to make the code more appropriate for - embedding in a HTML document. The "white" part of the code is actually - transparent. The code itself has a class of "pyqrcode". The lines - making up the QR code have a class "pyqrline". These should make the - code easier to style using CSS. - - You can also set the colors directly using the *module_color* and - *background* parameters. The *module_color* parameter sets what color to - use for the data modules (the black part on most QR codes). The - *background* parameter sets what color to use for the background (the - white part on most QR codes). The parameters can be set to any valid - SVG or HTML color. If the background is set to None, then no background - will be drawn, i.e. the background will be transparent. Note, many color - combinations are unreadable by scanners, so be careful. - - Example: - >>> code = pyqrcode.create('Hello. Uhh, can we have your liver?') - >>> code.svg('live-organ-transplants.svg', 3.6) - >>> code.svg('live-organ-transplants.svg', scale=4, - module_color='brown', background='0xFFFFFF') - """ - builder._svg(self.code, self.version, file, scale, - module_color, background) - - def text(self): - """This method returns a string based representation of the QR code. - The data modules are represented by 1's and the background modules are - represented by 0's. The main purpose of this method is to allow a user - to write their own renderer. - - Example: - >>> code = pyqrcode.create('Example') - >>> text = code.text() - >>> print(text) - """ - return builder._text(self.code) diff --git a/pyqrcode/builder.py b/pyqrcode/builder.py deleted file mode 100644 index 96ac93f..0000000 --- a/pyqrcode/builder.py +++ /dev/null @@ -1,1089 +0,0 @@ -"""This module does the actual generation of the QR codes. The QRCodeBuilder -builds the code. While the various output methods draw the code into a file. -""" -import pyqrcode.tables as tables -import io -import sys -import itertools - -class QRCodeBuilder: - """This class generates a QR code based on the standard. It is meant to - be used internally, not by users!!! - - This class implements the tutorials found at: - - * http://www.thonky.com/qr-code-tutorial/ - - * http://www.matchadesign.com/blog/qr-code-demystified-part-6/ - - This class also uses the standard, which can be read online at: - http://raidenii.net/files/datasheets/misc/qr_code.pdf - - Test codes were tested against: - http://zxing.org/w/decode.jspx - - Also, reference codes were generated at: - http://www.morovia.com/free-online-barcode-generator/qrcode-maker.php - - QR code Debugger: - http://qrlogo.kaarposoft.dk/qrdecode.html - """ - def __init__(self, data, version, mode, error): - """See :py:class:`pyqrcode.QRCode` for information on the parameters.""" - - #Set what data we are going to use to generate - #the QR code - if isinstance(data, bytes): - self.data = data.decode('utf-8') - else: - self.data = data - - #Check that the user passed in a valid mode - if mode in tables.modes.keys(): - self.mode = tables.modes[mode] - else: - raise LookupError('{} is not a valid mode.'.format(mode)) - - #Check that the user passed in a valid error level - if error in tables.error_level.keys(): - self.error = tables.error_level[error] - else: - raise LookupError('{} is not a valid error ' - 'level.'.format(error)) - - if 1 <= version <= 40: - self.version = version - else: - raise ValueError("The version must between 1 and 40.") - - #Look up the proper row for error correction code words - self.error_code_words = tables.eccwbi[version][self.error] - - #This property will hold the binary string as it is built - self.buffer = io.StringIO() - - #Create the binary data block - self.add_data() - - #Create the actual QR code - self.make_code() - - def grouper(self, n, iterable, fillvalue=None): - """This generator yields a set of tuples, where the - iterable is broken into n sized chunks. If the - iterable is not evenly sized then fillvalue will - be appended to the last tuple to make up the difference. - - This function is copied from the standard docs on - itertools. - """ - args = [iter(iterable)] * n - return itertools.zip_longest(*args, fillvalue=fillvalue) - - def binary_string(self, data, length): - """This method returns a string of length n that is the binary - representation of the given data. This function is used to - basically create bit fields of a given size. - """ - return '{{:0{}b}}'.format(length).format(int(data)) - - def get_data_length(self): - """QR codes contain a "data length" field. This method creates this - field. A binary string representing the appropriate length is - returned. - """ - - #The "data length" field varies by the type of code and its mode. - #discover how long the "data length" field should be. - if 1 <= self.version <= 9: - max_version = 9 - elif 10 <= self.version <= 26: - max_version = 26 - elif 27 <= self.version <= 40: - max_version = 40 - - data_length = tables.data_length_field[max_version][self.mode] - - length_string = self.binary_string(len(self.data), data_length) - - if len(length_string) > data_length: - raise ValueError('The supplied data will not fit ' - 'within this version of a QRCode.') - return length_string - - def encode(self): - """This method encodes the data into a binary string using - the appropriate algorithm specified by the mode. - """ - if self.mode == tables.modes['alphanumeric']: - encoded = self.encode_alphanumeric() - elif self.mode == tables.modes['numeric']: - encoded = self.encode_numeric() - elif self.mode == tables.modes['bytes']: - encoded = self.encode_bytes() - else: - raise ValueError('This mode is not yet implemented.') - - bits = self.terminate_bits(encoded) - if bits is not None: - encoded += bits - - return encoded - - def encode_alphanumeric(self): - """This method encodes the QR code's data if its mode is - alphanumeric. It returns the data encoded as a binary string. - """ - #Convert the string to upper case - self.data = self.data.upper() - - #Change the data such that it uses a QR code ascii table - ascii = [] - for char in self.data: - ascii.append(tables.ascii_codes[char]) - - #Now perform the algorithm that will make the ascii into bit fields - with io.StringIO() as buf: - for (a,b) in self.grouper(2, ascii): - if b is not None: - buf.write(self.binary_string((45*a)+b, 11)) - else: - #This occurs when there is an odd number - #of characters in the data - buf.write(self.binary_string(a, 6)) - - #Return the binary string - return buf.getvalue() - - def encode_numeric(self): - """This method encodes the QR code's data if its mode is - numeric. It returns the data encoded as a binary string. - """ - with io.StringIO() as buf: - #Break the number into groups of three digits - for triplet in self.grouper(3, self.data): - number = '' - for digit in triplet: - #Only build the string if digit is not None - if digit: - number = ''.join([number, digit]) - else: - break - - #If the number is one digits, make a 4 bit field - if len(number) == 1: - bin = self.binary_string(number, 4) - - #If the number is two digits, make a 7 bit field - elif len(number) == 2: - bin = self.binary_string(number, 7) - - #Three digit numbers use a 10 bit field - else: - bin = self.binary_string(number, 10) - - buf.write(bin) - return buf.getvalue() - - def encode_bytes(self): - """This method encodes the QR code's data if its mode is - 8 bit mode. It returns the data encoded as a binary string. - """ - with io.StringIO() as buf: - for char in self.data.encode('latin1'): - buf.write('{{:0{}b}}'.format(8).format(char)) - return buf.getvalue() - - - def add_data(self): - """This function properly constructs a QR code's data string. It takes - into account the interleaving pattern required by the standard. - """ - #Encode the data into a QR code - self.buffer.write(self.binary_string(self.mode, 4)) - self.buffer.write(self.get_data_length()) - self.buffer.write(self.encode()) - - #delimit_words and add_words can return None - add_bits = self.delimit_words() - if add_bits: - self.buffer.write(add_bits) - - fill_bytes = self.add_words() - if fill_bytes: - self.buffer.write(fill_bytes) - - #Get a numeric representation of the data - data = [int(''.join(x),2) - for x in self.grouper(8, self.buffer.getvalue())] - - #This is the error information for the code - error_info = tables.eccwbi[self.version][self.error] - - #This will hold our data blocks - data_blocks = [] - - #This will hold our error blocks - error_blocks = [] - - #Some codes have the data sliced into two different sized blocks - #for example, first two 14 word sized blocks, then four 15 word - #sized blocks. This means that slicing size can change over time. - data_block_sizes = [error_info[2]] * error_info[1] - if error_info[3] != 0: - data_block_sizes.extend([error_info[4]] * error_info[3]) - - #For every block of data, slice the data into the appropriate - #sized block - current_byte = 0 - for n_data_blocks in data_block_sizes: - data_blocks.append(data[current_byte:current_byte+n_data_blocks]) - current_byte += n_data_blocks - - if current_byte < len(data): - raise ValueError('Too much data for this code version.') - - #DEBUG CODE!!!! - #Print out the data blocks - #print('Data Blocks:\n{}'.format(data_blocks)) - - #Calculate the error blocks - for n, block in enumerate(data_blocks): - error_blocks.append(self.make_error_block(block, n)) - - #DEBUG CODE!!!! - #Print out the error blocks - #print('Error Blocks:\n{}'.format(error_blocks)) - - #Buffer we will write our data blocks into - data_buffer = io.StringIO() - - #Add the data blocks - #Write the buffer such that: block 1 byte 1, block 2 byte 1, etc. - largest_block = max(error_info[2], error_info[4])+error_info[0] - for i in range(largest_block): - for block in data_blocks: - if i < len(block): - data_buffer.write(self.binary_string(block[i], 8)) - - #Add the error code blocks. - #Write the buffer such that: block 1 byte 1, block 2 byte 2, etc. - for i in range(error_info[0]): - for block in error_blocks: - data_buffer.write(self.binary_string(block[i], 8)) - - self.buffer = data_buffer - - def terminate_bits(self, payload): - """This method adds zeros to the end of the encoded data so that the - encoded data is of the correct length. It returns a binary string - containing the bits to be added. - """ - data_capacity = tables.data_capacity[self.version][self.error][0] - - if len(payload) > data_capacity: - raise ValueError('The supplied data will not fit ' - 'within this version of a QR code.') - - #We must add up to 4 zeros to make up for any shortfall in the - #length of the data field. - if len(payload) == data_capacity: - return None - elif len(payload) <= data_capacity-4: - bits = self.binary_string(0,4) - else: - #Make up any shortfall need with less than 4 zeros - bits = self.binary_string(0, data_capacity - len(payload)) - - return bits - - def delimit_words(self): - """This method takes the existing encoded binary string - and returns a binary string that will pad it such that - the encoded string contains only full bytes. - """ - bits_short = 8 - (len(self.buffer.getvalue()) % 8) - - #The string already falls on an byte boundary do nothing - if bits_short == 8: - return None - else: - return self.binary_string(0, bits_short) - - def add_words(self): - """The data block must fill the entire data capacity of the QR code. - If we fall short, then we must add bytes to the end of the encoded - data field. The value of these bytes are specified in the standard. - """ - - data_blocks = len(self.buffer.getvalue()) // 8 - total_blocks = tables.data_capacity[self.version][self.error][0] // 8 - needed_blocks = total_blocks - data_blocks - - if needed_blocks == 0: - return None - - #This will return item1, item2, item1, item2, etc. - block = itertools.cycle(['11101100', '00010001']) - - #Create a string of the needed blocks - return ''.join([next(block) for x in range(needed_blocks)]) - - def _fix_exp(self, exponent): - """Makes sure the exponent ranges from 0 to 255.""" - #return (exponent % 256) + (exponent // 256) - return exponent % 255 - - def make_error_block(self, block, block_number): - """This function constructs the error correction block of the - given data block. This is *very complicated* process. To - understand the code you need to read: - - * http://www.thonky.com/qr-code-tutorial/part-2-error-correction/ - * http://www.matchadesign.com/blog/qr-code-demystified-part-4/ - """ - #Get the error information from the standards table - error_info = tables.eccwbi[self.version][self.error] - - #This is the number of 8-bit words per block - if block_number < error_info[1]: - code_words_per_block = error_info[2] - else: - code_words_per_block = error_info[4] - - #This is the size of the error block - error_block_size = error_info[0] - - #Copy the block as the message polynomial coefficients - mp_co = block[:] - - #Add the error blocks to the message polynomial - mp_co.extend([0] * (error_block_size)) - - #Get the generator polynomial - generator = tables.generator_polynomials[error_block_size] - - #This will hold the temporary sum of the message coefficient and the - #generator polynomial - gen_result = [0] * len(generator) - - #Go through every code word in the block - for i in range(code_words_per_block): - #Get the first coefficient from the message polynomial - coefficient = mp_co.pop(0) - - #Skip coefficients that are zero - if coefficient == 0: - continue - else: - #Turn the coefficient into an alpha exponent - alpha_exp = tables.galois_antilog[coefficient] - - #Add the alpha to the generator polynomial - for n in range(len(generator)): - gen_result[n] = alpha_exp + generator[n] - if gen_result[n] > 255: - gen_result[n] = gen_result[n] % 255 - - #Convert the alpha notation back into coefficients - gen_result[n] = tables.galois_log[gen_result[n]] - - #XOR the sum with the message coefficients - mp_co[n] = gen_result[n] ^ mp_co[n] - - #Pad the end of the error blocks with zeros if needed - if len(mp_co) < code_words_per_block: - mp_co.extend([0] * (code_words_per_block - len(mp_co))) - - return mp_co - - def make_code(self): - """This method returns the best possible QR code.""" - from copy import deepcopy - - #Get the size of the underlying matrix - matrix_size = tables.version_size[self.version] - - #Create a template matrix we will build the codes with - row = [' ' for x in range(matrix_size)] - template = [deepcopy(row) for x in range(matrix_size)] - - #Add mandatory information to the template - self.add_detection_pattern(template) - self.add_position_pattern(template) - self.add_version_pattern(template) - - #Create the various types of masks of the template - self.masks = self.make_masks(template) - - self.best_mask = self.choose_best_mask() - self.code = self.masks[self.best_mask] - - def add_detection_pattern(self, m): - """This method add the detection patterns to the QR code. This lets - the scanner orient the pattern. It is required for all QR codes. - The detection pattern consists of three boxes located at the upper - left, upper right, and lower left corners of the matrix. Also, two - special lines called the timing pattern is also necessary. Finally, - a single black pixel is added just above the lower left black box. - """ - - #Draw outer black box - for i in range(7): - inv = -(i+1) - for j in [0,6,-1,-7]: - m[j][i] = 1 - m[i][j] = 1 - m[inv][j] = 1 - m[j][inv] = 1 - - #Draw inner white box - for i in range(1, 6): - inv = -(i+1) - for j in [1, 5, -2, -6]: - m[j][i] = 0 - m[i][j] = 0 - m[inv][j] = 0 - m[j][inv] = 0 - - #Draw inner black box - for i in range(2, 5): - for j in range(2, 5): - inv = -(i+1) - m[i][j] = 1 - m[inv][j] = 1 - m[j][inv] = 1 - - #Draw white border - for i in range(8): - inv = -(i+1) - for j in [7, -8]: - m[i][j] = 0 - m[j][i] = 0 - m[inv][j] = 0 - m[j][inv] = 0 - - #To keep the code short, it draws an extra box - #in the lower right corner, this removes it. - for i in range(-8, 0): - for j in range(-8, 0): - m[i][j] = ' ' - - #Add the timing pattern - bit = itertools.cycle([1,0]) - for i in range(8, (len(m)-8)): - b = next(bit) - m[i][6] = b - m[6][i] = b - - #Add the extra black pixel - m[-8][8] = 1 - - def add_position_pattern(self, m): - """This method draws the position adjustment patterns onto the QR - Code. All QR code versions larger than one require these special boxes - called position adjustment patterns. - """ - #Version 1 does not have a position adjustment pattern - if self.version == 1: - return - - #Get the coordinates for where to place the boxes - coordinates = tables.position_adjustment[self.version] - - #Get the max and min coordinates to handle special cases - min_coord = coordinates[0] - max_coord = coordinates[-1] - - #Draw a box at each intersection of the coordinates - for i in coordinates: - for j in coordinates: - #Do not draw these boxes because they would - #interfere with the detection pattern - if (i == min_coord and j == min_coord) or \ - (i == min_coord and j == max_coord) or \ - (i == max_coord and j == min_coord): - continue - - #Center black pixel - m[i][j] = 1 - - #Surround the pixel with a white box - for x in [-1,1]: - m[i+x][j+x] = 0 - m[i+x][j] = 0 - m[i][j+x] = 0 - m[i-x][j+x] = 0 - m[i+x][j-x] = 0 - - #Surround the white box with a black box - for x in [-2,2]: - for y in [0,-1,1]: - m[i+x][j+x] = 1 - m[i+x][j+y] = 1 - m[i+y][j+x] = 1 - m[i-x][j+x] = 1 - m[i+x][j-x] = 1 - - def add_version_pattern(self, m): - """For QR codes with a version 7 or higher, a special pattern - specifying the code's version is required. - - For further information see: - http://www.thonky.com/qr-code-tutorial/format-version-information/#example-of-version-7-information-string - """ - if self.version < 7: - return - - #Get the bit fields for this code's version - #We will iterate across the string, the bit string - #needs the least significant digit in the zero-th position - field = iter(tables.version_pattern[self.version][::-1]) - - #Where to start placing the pattern - start = len(m)-11 - - #The version pattern is pretty odd looking - for i in range(6): - #The pattern is three modules wide - for j in range(start, start+3): - bit = int(next(field)) - - #Bottom Left - m[i][j] = bit - - #Upper right - m[j][i] = bit - - def make_masks(self, template): - """This method generates all seven masks so that the best mask can - be determined. The template parameter is a code matrix that will - server as the base for all the generated masks. - """ - from copy import deepcopy - - nmasks = len(tables.mask_patterns) - masks = [''] * nmasks - count = 0 - - for n in range(nmasks): - cur_mask = deepcopy(template) - masks[n] = cur_mask - - #Add the type pattern bits to the code - self.add_type_pattern(cur_mask, tables.type_bits[self.error][n]) - - #Get the mask pattern - pattern = tables.mask_patterns[n] - - #This will read the 1's and 0's one at a time - bits = iter(self.buffer.getvalue()) - - #These will help us do the up, down, up, down pattern - row_start = itertools.cycle([len(cur_mask)-1, 0]) - row_stop = itertools.cycle([-1,len(cur_mask)]) - direction = itertools.cycle([-1, 1]) - - #The data pattern is added using pairs of columns - for column in range(len(cur_mask)-1, 0, -2): - - #The vertical timing pattern is an exception to the rules, - #move the column counter over by one - if column <= 6: - column = column - 1 - - #This will let us fill in the pattern - #right-left, right-left, etc. - column_pair = itertools.cycle([column, column-1]) - - #Go through each row in the pattern moving up, then down - for row in range(next(row_start), next(row_stop), - next(direction)): - - #Fill in the right then left column - for i in range(2): - col = next(column_pair) - - #Go to the next column if we encounter a - #preexisting pattern (usually an alignment pattern) - if cur_mask[row][col] != ' ': - continue - - #Some versions don't have enough bits. You then fill - #in the rest of the pattern with 0's. These are - #called "remainder bits." - try: - bit = int(next(bits)) - except: - bit = 0 - - - #If the pattern is True then flip the bit - if pattern(row, col): - cur_mask[row][col] = bit ^ 1 - else: - cur_mask[row][col] = bit - - #DEBUG CODE!!! - #Save all of the masks as png files - #for i, m in enumerate(masks): - # _png(m, self.version, 'mask-{}.png'.format(i), 5) - - return masks - - def choose_best_mask(self): - """This method returns the index of the "best" mask as defined by - having the lowest total penalty score. The penalty rules are defined - by the standard. The mask with the lowest total score should be the - easiest to read by optical scanners. - """ - self.scores = [] - for n in range(len(self.masks)): - self.scores.append([0,0,0,0]) - - #Score penalty rule number 1 - #Look for five consecutive squares with the same color. - #Each one found gets a penalty of 3 + 1 for every - #same color square after the first five in the row. - for (n, mask) in enumerate(self.masks): - current = mask[0][0] - counter = 0 - total = 0 - - #Examine the mask row wise - for row in range(0,len(mask)): - counter = 0 - for col in range(0,len(mask)): - bit = mask[row][col] - - if bit == current: - counter += 1 - else: - if counter >= 5: - total += (counter - 5) + 3 - counter = 1 - current = bit - if counter >= 5: - total += (counter - 5) + 3 - - #Examine the mask column wise - for col in range(0,len(mask)): - counter = 0 - for row in range(0,len(mask)): - bit = mask[row][col] - - if bit == current: - counter += 1 - else: - if counter >= 5: - total += (counter - 5) + 3 - counter = 1 - current = bit - if counter >= 5: - total += (counter - 5) + 3 - - self.scores[n][0] = total - - #Score penalty rule 2 - #This rule will add 3 to the score for each 2x2 block of the same - #colored pixels there are. - for (n, mask) in enumerate(self.masks): - count = 0 - #Don't examine the 0th and Nth row/column - for i in range(0, len(mask)-1): - for j in range(0, len(mask)-1): - if mask[i][j] == mask[i+1][j] and \ - mask[i][j] == mask[i][j+1] and \ - mask[i][j] == mask[i+1][j+1]: - count += 1 - - self.scores[n][1] = count * 3 - - #Score penalty rule 3 - #This rule looks for 1011101 within the mask prefixed - #and/or suffixed by four zeros. - patterns = [[0,0,0,0,1,0,1,1,1,0,1], - [1,0,1,1,1,0,1,0,0,0,0],] - #[0,0,0,0,1,0,1,1,1,0,1,0,0,0,0]] - - for (n, mask) in enumerate(self.masks): - nmatches = 0 - - for i in range(len(mask)): - for j in range(len(mask)): - for pattern in patterns: - match = True - k = j - #Look for row matches - for p in pattern: - if k >= len(mask) or mask[i][k] != p: - match = False - break - k += 1 - if match: - nmatches += 1 - - match = True - k = j - #Look for column matches - for p in pattern: - if k >= len(mask) or mask[k][i] != p: - match = False - break - k += 1 - if match: - nmatches += 1 - - - self.scores[n][2] = nmatches * 40 - - #Score the last rule, penalty rule 4. This rule measures how close - #the pattern is to being 50% black. The further it deviates from - #this this ideal the higher the penalty. - for (n, mask) in enumerate(self.masks): - nblack = 0 - for row in mask: - nblack += sum(row) - - total_pixels = len(mask)**2 - ratio = nblack / total_pixels - percent = (ratio * 100) - 50 - self.scores[n][3] = int((abs(int(percent)) / 5) * 10) - - - #Calculate the total for each score - totals = [0] * len(self.scores) - for i in range(len(self.scores)): - for j in range(len(self.scores[i])): - totals[i] += self.scores[i][j] - - #DEBUG CODE!!! - #Prints out a table of scores - #print('Rule Scores\n 1 2 3 4 Total') - #for i in range(len(self.scores)): - # print(i, end='') - # for s in self.scores[i]: - # print('{: >6}'.format(s), end='') - # print('{: >7}'.format(totals[i])) - #print('Mask Chosen: {}'.format(totals.index(min(totals)))) - - #The lowest total wins - return totals.index(min(totals)) - - def add_type_pattern(self, m, type_bits): - """This will add the pattern to the QR code that represents the error - level and the type of mask used to make the code. - """ - field = iter(type_bits) - for i in range(7): - bit = int(next(field)) - - #Skip the timing bits - if i < 6: - m[8][i] = bit - else: - m[8][i+1] = bit - - if -8 < -(i+1): - m[-(i+1)][8] = bit - - for i in range(-8,0): - bit = int(next(field)) - - m[8][i] = bit - - i = -i - #Skip timing column - if i > 6: - m[i][8] = bit - else: - m[i-1][8] = bit - -############################################################################## -############################################################################## -# -# Output Functions -# -############################################################################## -############################################################################## - -def _get_file(file, mode): - """This method returns the file parameter if it is an open writable - stream. Otherwise it treats the file parameter as a file path and - opens it with the given mode. It is used by the svg and png methods - to interpret the file parameter. - """ - import os.path - #See if the file parameter is a stream - if not isinstance(file, io.IOBase): - #If it is not a stream open a the file path - return open(os.path.abspath(file), mode) - elif not file.writable(): - raise ValueError('Stream is not writable.') - else: - return file - -def _get_png_size(version, scale): - """See: QRCode.get_png_size - - This function was abstracted away from QRCode to allow for the output of - QR codes during the build process, i.e. for debugging. It works - just the same except you must specify the code's version. This is needed - to calculate the PNG's size. - """ - #Formula: scale times number of modules plus the border on each side - return (scale * tables.version_size[version]) + (2 * scale) - -def _text(code): - """This method returns a text based representation of the QR code. - This is useful for debugging purposes. - """ - buf = io.StringIO() - - border_row = '0' * (len(code[0]) + 2) - - buf.write(border_row) - buf.write('\n') - for row in code: - buf.write('0') - for bit in row: - if bit == 1: - buf.write('1') - elif bit == 0: - buf.write('0') - #This is for debugging unfinished QR codes, - #unset pixels will be spaces. - else: - buf.write(' ') - buf.write('0\n') - - buf.write(border_row) - - return buf.getvalue() - -def _svg(code, version, file, scale=1, module_color='black', background=None): - """This method writes the QR code out as an SVG document. The - code is drawn by drawing only the modules corresponding to a 1. They - are drawn using a line, such that contiguous modules in a row - are drawn with a single line. The file parameter is used to - specify where to write the document to. It can either be an writable - stream or a file path. The scale parameter is sets how large to draw - a single module. By default one pixel is used to draw a single - module. This may make the code to small to be read efficiently. - Increasing the scale will make the code larger. This method will accept - fractional scales (e.g. 2.5). - """ - #This is the template for the svg line. It is placed here so it - #does not need to be recreated for each call to line(). - line_template = ''' - ''' - - def line(x1, y1, x2, y2, color): - """This sub-function draws the modules. It attempts to draw them - as a single line per row, rather than as individual rectangles. - It uses the l variable as a template t - """ - return line_template.format(x1+scale, y1+scale, x2+scale, y2+scale, - color, scale) - - file = _get_file(file, 'w') - - #Write the document header - file.write(""" - - - - QR code - """.format((tables.version_size[version]*scale)+(2*scale))) - - #Draw a background rectangle if necessary - if background: - file.write(""" - - """.format((tables.version_size[version]*scale)+(2*scale), - background)) - - #This will hold the current row number - rnumber = 0 - - #The current "color," used to define starting a line and ending a line. - color = 'black' - - #Loop through each row of the code - for row in code: - colnumber = 0 #Reset column number - start_column = 0 #Reset the starting_column number - - #Examine every bit in the row - for bit in row: - #Set the color of the bit - if bit == 1: - new_color = 'black' - elif bit == 0: - new_color = 'white' - - #DEBUG CODE!! - #In unfinished QR codes, unset pixels will be red - #else: - #new_color = 'red' - - #When the color changes then draw a line - if new_color != color: - #Don't draw the white background - if color != 'white': - file.write(line(start_column, rnumber, - colnumber, rnumber, color)) - - #Move the next line's starting color and number - start_column = colnumber - color = new_color - - #Accumulate the column - colnumber += scale - - #End the row by drawing out the accumulated line - #if it is not the background - if color != 'white': - file.write(line(start_column, rnumber, - colnumber, rnumber, color)) - - #Set row number - rnumber += scale - - - #Close the document - file.write("\n") - -def _png(code, version, file, scale=1, module_color=None, background=None): - """See: pyqrcode.QRCode.png() - - This function was abstracted away from QRCode to allow for the output of - QR codes during the build process, i.e. for debugging. It works - just the same except you must specify the code's version. This is needed - to calculate the PNG's size. - - This method will write the given file out as a PNG file. Note, it - depends on the PyPNG module to do this. - """ - from . import png - - #Coerce scale parameter into an integer - try: - scale = int(scale) - except ValueError: - raise ValueError('The scale parameter must be an integer') - - def scale_code(code): - """To perform the scaling we need to inflate the number of bits. - The PNG library expects all of the bits when it draws the PNG. - Effectively, we double, tripple, etc. the number of columns and - the number of rows. - """ - #This is the row to show up at the top and bottom border - border_module = [1] * scale - border_row = [1] * _get_png_size(version, scale) - border = [border_row] * scale - - - #This is one row's worth of each possible module - #PNG's use 0 for black and 1 for white, this is the - #reverse of the QR standard - black = [0] * scale - white = [1] * scale - - #This will hold the final PNG's bits - bits = [] - - #Add scale rows before the code as a border, - #as per the standard - bits.extend(border) - - #Add each row of the to the final PNG bits - for row in code: - tmp_row = [] - - #Add one all white module to the beginning - #to create the vertical border - tmp_row.extend(border_module) - - #Go through each bit in the code - for item in row: - #Add one scaled module - if item == 0: - tmp_row.extend(white) - else: - tmp_row.extend(black) - - #Add one all white module to the end - #to create the vertical border - tmp_row.extend(border_module) - - #Copy each row scale times - for n in range(scale): - bits.append(tmp_row) - - #Add the bottom border - bits.extend(border) - - return bits - - def png_pallete_color(color): - """This creates a palette color from a list or tuple. The list or - tuple must be of length 3 (for rgb) or 4 (for rgba). The values - must be between 0 and 255. Note rgb colors will be given an added - alpha component set to 255. - - The pallete color is represented as a list, this is what is returned. - """ - if color: - rgba = [] - if not (3 <= len(color) <= 4): - raise ValueError('Colors must be a list or tuple of length ' - ' 3 or 4. You passed in ' - '"{}".'.format(color)) - - for c in color: - c = int(c) - if 0 <= c <= 255: - rgba.append(int(c)) - else: - raise ValueError('Color components must be between ' - ' 0 and 255') - - #Make all all colors have an alpha channel - if len(rgba) == 3: - rgba.append(255) - - return rgba - - #If the user passes in one parameter, then they must pass in both or neither - #Note, this is a logical xor - if (not module_color) != (not background): - raise ValueError('If you specify either the black or white parameter, ' - 'then you must specify both.') - - #Create the pallete, or set greyscale to True - if module_color: - palette = [png_pallete_color(module_color), - png_pallete_color(background)] - greyscale = False - else: - palette = None - greyscale = True - - #The size of the PNG - size = _get_png_size(version, scale) - - #We need to increase the size of the code to match up to the - #scale parameter. - code = scale_code(code) - - #Write out the PNG - with _get_file(file, 'wb') as f: - w = png.Writer(width=size, height=size, greyscale=greyscale, - palette=palette, bitdepth=1) - - w.write(f, code) diff --git a/pyqrcode/png.py b/pyqrcode/png.py deleted file mode 100644 index fc1f469..0000000 --- a/pyqrcode/png.py +++ /dev/null @@ -1,3838 +0,0 @@ -#!/usr/bin/env python - -# png.py - PNG encoder/decoder in pure Python -# -# Copyright (C) 2006 Johann C. Rocholl -# Portions Copyright (C) 2009 David Jones -# And probably portions Copyright (C) 2006 Nicko van Someren -# -# Original concept by Johann C. Rocholl. -# -# LICENCE (MIT) -# -# 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. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS -# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN -# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN -# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -# SOFTWARE. -# -# Changelog (recent first): -# 2009-03-11 David: interlaced bit depth < 8 (writing). -# 2009-03-10 David: interlaced bit depth < 8 (reading). -# 2009-03-04 David: Flat and Boxed pixel formats. -# 2009-02-26 David: Palette support (writing). -# 2009-02-23 David: Bit-depths < 8; better PNM support. -# 2006-06-17 Nicko: Reworked into a class, faster interlacing. -# 2006-06-17 Johann: Very simple prototype PNG decoder. -# 2006-06-17 Nicko: Test suite with various image generators. -# 2006-06-17 Nicko: Alpha-channel, grey-scale, 16-bit/plane support. -# 2006-06-15 Johann: Scanline iterator interface for large input files. -# 2006-06-09 Johann: Very simple prototype PNG encoder. - -# Incorporated into Bangai-O Development Tools by drj on 2009-02-11 from -# http://trac.browsershots.org/browser/trunk/pypng/lib/png.py?rev=2885 - -# Incorporated into pypng by drj on 2009-03-12 from -# //depot/prj/bangaio/master/code/png.py#67 - - -""" -Pure Python PNG Reader/Writer - -This Python module implements support for PNG images (see PNG -specification at http://www.w3.org/TR/2003/REC-PNG-20031110/ ). It reads -and writes PNG files with all allowable bit depths (1/2/4/8/16/24/32/48/64 -bits per pixel) and colour combinations: greyscale (1/2/4/8/16 bit); RGB, -RGBA, LA (greyscale with alpha) with 8/16 bits per channel; colour mapped -images (1/2/4/8 bit). Adam7 interlacing is supported for reading and -writing. A number of optional chunks can be specified (when writing) -and understood (when reading): ``tRNS``, ``bKGD``, ``gAMA``. - -For help, type ``import png; help(png)`` in your python interpreter. - -A good place to start is the :class:`Reader` and :class:`Writer` classes. - -Requires Python 2.3. Limited support is available for Python 2.2, but -not everything works. Best with Python 2.4 and higher. Installation is -trivial, but see the ``README.txt`` file (with the source distribution) -for details. - -This file can also be used as a command-line utility to convert -`Netpbm `_ PNM files to PNG, and the reverse conversion from PNG to -PNM. The interface is similar to that of the ``pnmtopng`` program from -Netpbm. Type ``python png.py --help`` at the shell prompt -for usage and a list of options. - -A note on spelling and terminology ----------------------------------- - -Generally British English spelling is used in the documentation. So -that's "greyscale" and "colour". This not only matches the author's -native language, it's also used by the PNG specification. - -The major colour models supported by PNG (and hence by PyPNG) are: -greyscale, RGB, greyscale--alpha, RGB--alpha. These are sometimes -referred to using the abbreviations: L, RGB, LA, RGBA. In this case -each letter abbreviates a single channel: *L* is for Luminance or Luma or -Lightness which is the channel used in greyscale images; *R*, *G*, *B* stand -for Red, Green, Blue, the components of a colour image; *A* stands for -Alpha, the opacity channel (used for transparency effects, but higher -values are more opaque, so it makes sense to call it opacity). - -A note on formats ------------------ - -When getting pixel data out of this module (reading) and presenting -data to this module (writing) there are a number of ways the data could -be represented as a Python value. Generally this module uses one of -three formats called "flat row flat pixel", "boxed row flat pixel", and -"boxed row boxed pixel". Basically the concern is whether each pixel -and each row comes in its own little tuple (box), or not. - -Consider an image that is 3 pixels wide by 2 pixels high, and each pixel -has RGB components: - -Boxed row flat pixel:: - - list([R,G,B, R,G,B, R,G,B], - [R,G,B, R,G,B, R,G,B]) - -Each row appears as its own list, but the pixels are flattened so that -three values for one pixel simply follow the three values for the previous -pixel. This is the most common format used, because it provides a good -compromise between space and convenience. PyPNG regards itself as -at liberty to replace any sequence type with any sufficiently compatible -other sequence type; in practice each row is an array (from the array -module), and the outer list is sometimes an iterator rather than an -explicit list (so that streaming is possible). - -Flat row flat pixel:: - - [R,G,B, R,G,B, R,G,B, - R,G,B, R,G,B, R,G,B] - -The entire image is one single giant sequence of colour values. -Generally an array will be used (to save space), not a list. - -Boxed row boxed pixel:: - - list([ (R,G,B), (R,G,B), (R,G,B) ], - [ (R,G,B), (R,G,B), (R,G,B) ]) - -Each row appears in its own list, but each pixel also appears in its own -tuple. A serious memory burn in Python. - -In all cases the top row comes first, and for each row the pixels are -ordered from left-to-right. Within a pixel the values appear in the -order, R-G-B-A (or L-A for greyscale--alpha). - -There is a fourth format, mentioned because it is used internally, -is close to what lies inside a PNG file itself, and has some support -from the public API. This format is called packed. When packed, -each row is a sequence of bytes (integers from 0 to 255), just as -it is before PNG scanline filtering is applied. When the bit depth -is 8 this is essentially the same as boxed row flat pixel; when the -bit depth is less than 8, several pixels are packed into each byte; -when the bit depth is 16 (the only value more than 8 that is supported -by the PNG image format) each pixel value is decomposed into 2 bytes -(and `packed` is a misnomer). This format is used by the -:meth:`Writer.write_packed` method. It isn't usually a convenient -format, but may be just right if the source data for the PNG image -comes from something that uses a similar format (for example, 1-bit -BMPs, or another PNG file). - -And now, my famous members --------------------------- -""" - -# http://www.python.org/doc/2.2.3/whatsnew/node5.html - - -__version__ = "0.0.16" - -from array import array -from functools import reduce -try: # See :pyver:old - import itertools -except: - pass -import math -# http://www.python.org/doc/2.4.4/lib/module-operator.html -import operator -import struct -import sys -import zlib -# http://www.python.org/doc/2.4.4/lib/module-warnings.html -import warnings -try: - import pyximport - pyximport.install() - import cpngfilters as pngfilters -except ImportError: - pass - - -__all__ = ['Image', 'Reader', 'Writer', 'write_chunks', 'from_array'] - - -# The PNG signature. -# http://www.w3.org/TR/PNG/#5PNG-file-signature -_signature = struct.pack('8B', 137, 80, 78, 71, 13, 10, 26, 10) - -_adam7 = ((0, 0, 8, 8), - (4, 0, 8, 8), - (0, 4, 4, 8), - (2, 0, 4, 4), - (0, 2, 2, 4), - (1, 0, 2, 2), - (0, 1, 1, 2)) - -def group(s, n): - # See - # http://www.python.org/doc/2.6/library/functions.html#zip - return list(zip(*[iter(s)]*n)) - -def isarray(x): - """Same as ``isinstance(x, array)`` except on Python 2.2, where it - always returns ``False``. This helps PyPNG work on Python 2.2. - """ - - try: - return isinstance(x, array) - except: - return False - -try: # see :pyver:old - array.tostring -except: - def tostring(row): - l = len(row) - return struct.pack('%dB' % l, *row) -else: - def tostring(row): - """Convert row of bytes to string. Expects `row` to be an - ``array``. - """ - return row.tostring() - -# Conditionally convert to bytes. Works on Python 2 and Python 3. -try: - bytes('', 'ascii') - def strtobytes(x): return bytes(x, 'iso8859-1') - def bytestostr(x): return str(x, 'iso8859-1') -except: - strtobytes = str - bytestostr = str - -def interleave_planes(ipixels, apixels, ipsize, apsize): - """ - Interleave (colour) planes, e.g. RGB + A = RGBA. - - Return an array of pixels consisting of the `ipsize` elements of data - from each pixel in `ipixels` followed by the `apsize` elements of data - from each pixel in `apixels`. Conventionally `ipixels` and - `apixels` are byte arrays so the sizes are bytes, but it actually - works with any arrays of the same type. The returned array is the - same type as the input arrays which should be the same type as each other. - """ - - itotal = len(ipixels) - atotal = len(apixels) - newtotal = itotal + atotal - newpsize = ipsize + apsize - # Set up the output buffer - # See http://www.python.org/doc/2.4.4/lib/module-array.html#l2h-1356 - out = array(ipixels.typecode) - # It's annoying that there is no cheap way to set the array size :-( - out.extend(ipixels) - out.extend(apixels) - # Interleave in the pixel data - for i in range(ipsize): - out[i:newtotal:newpsize] = ipixels[i:itotal:ipsize] - for i in range(apsize): - out[i+ipsize:newtotal:newpsize] = apixels[i:atotal:apsize] - return out - -def check_palette(palette): - """Check a palette argument (to the :class:`Writer` class) for validity. - Returns the palette as a list if okay; raises an exception otherwise. - """ - - # None is the default and is allowed. - if palette is None: - return None - - p = list(palette) - if not (0 < len(p) <= 256): - raise ValueError("a palette must have between 1 and 256 entries") - seen_triple = False - for i,t in enumerate(p): - if len(t) not in (3,4): - raise ValueError( - "palette entry %d: entries must be 3- or 4-tuples." % i) - if len(t) == 3: - seen_triple = True - if seen_triple and len(t) == 4: - raise ValueError( - "palette entry %d: all 4-tuples must precede all 3-tuples" % i) - for x in t: - if int(x) != x or not(0 <= x <= 255): - raise ValueError( - "palette entry %d: values must be integer: 0 <= x <= 255" % i) - return p - -class Error(Exception): - prefix = 'Error' - def __str__(self): - return self.prefix + ': ' + ' '.join(self.args) - -class FormatError(Error): - """Problem with input file format. In other words, PNG file does - not conform to the specification in some way and is invalid. - """ - - prefix = 'FormatError' - -class ChunkError(FormatError): - prefix = 'ChunkError' - - -class Writer: - """ - PNG encoder in pure Python. - """ - - def __init__(self, width=None, height=None, - size=None, - greyscale=False, - alpha=False, - bitdepth=8, - palette=None, - transparent=None, - background=None, - gamma=None, - compression=None, - interlace=False, - bytes_per_sample=None, # deprecated - planes=None, - colormap=None, - maxval=None, - chunk_limit=2**20): - """ - Create a PNG encoder object. - - Arguments: - - width, height - Image size in pixels, as two separate arguments. - size - Image size (w,h) in pixels, as single argument. - greyscale - Input data is greyscale, not RGB. - alpha - Input data has alpha channel (RGBA or LA). - bitdepth - Bit depth: from 1 to 16. - palette - Create a palette for a colour mapped image (colour type 3). - transparent - Specify a transparent colour (create a ``tRNS`` chunk). - background - Specify a default background colour (create a ``bKGD`` chunk). - gamma - Specify a gamma value (create a ``gAMA`` chunk). - compression - zlib compression level: 0 (none) to 9 (more compressed); default: -1 or None. - interlace - Create an interlaced image. - chunk_limit - Write multiple ``IDAT`` chunks to save memory. - - The image size (in pixels) can be specified either by using the - `width` and `height` arguments, or with the single `size` - argument. If `size` is used it should be a pair (*width*, - *height*). - - `greyscale` and `alpha` are booleans that specify whether - an image is greyscale (or colour), and whether it has an - alpha channel (or not). - - `bitdepth` specifies the bit depth of the source pixel values. - Each source pixel value must be an integer between 0 and - ``2**bitdepth-1``. For example, 8-bit images have values - between 0 and 255. PNG only stores images with bit depths of - 1,2,4,8, or 16. When `bitdepth` is not one of these values, - the next highest valid bit depth is selected, and an ``sBIT`` - (significant bits) chunk is generated that specifies the original - precision of the source image. In this case the supplied pixel - values will be rescaled to fit the range of the selected bit depth. - - The details of which bit depth / colour model combinations the - PNG file format supports directly, are somewhat arcane - (refer to the PNG specification for full details). Briefly: - "small" bit depths (1,2,4) are only allowed with greyscale and - colour mapped images; colour mapped images cannot have bit depth - 16. - - For colour mapped images (in other words, when the `palette` - argument is specified) the `bitdepth` argument must match one of - the valid PNG bit depths: 1, 2, 4, or 8. (It is valid to have a - PNG image with a palette and an ``sBIT`` chunk, but the meaning - is slightly different; it would be awkward to press the - `bitdepth` argument into service for this.) - - The `palette` option, when specified, causes a colour mapped image - to be created: the PNG colour type is set to 3; greyscale - must not be set; alpha must not be set; transparent must - not be set; the bit depth must be 1,2,4, or 8. When a colour - mapped image is created, the pixel values are palette indexes - and the `bitdepth` argument specifies the size of these indexes - (not the size of the colour values in the palette). - - The palette argument value should be a sequence of 3- or - 4-tuples. 3-tuples specify RGB palette entries; 4-tuples - specify RGBA palette entries. If both 4-tuples and 3-tuples - appear in the sequence then all the 4-tuples must come - before all the 3-tuples. A ``PLTE`` chunk is created; if there - are 4-tuples then a ``tRNS`` chunk is created as well. The - ``PLTE`` chunk will contain all the RGB triples in the same - sequence; the ``tRNS`` chunk will contain the alpha channel for - all the 4-tuples, in the same sequence. Palette entries - are always 8-bit. - - If specified, the `transparent` and `background` parameters must - be a tuple with three integer values for red, green, blue, or - a simple integer (or singleton tuple) for a greyscale image. - - If specified, the `gamma` parameter must be a positive number - (generally, a float). A ``gAMA`` chunk will be created. Note that - this will not change the values of the pixels as they appear in - the PNG file, they are assumed to have already been converted - appropriately for the gamma specified. - - The `compression` argument specifies the compression level to - be used by the ``zlib`` module. Values from 1 to 9 specify - compression, with 9 being "more compressed" (usually smaller - and slower, but it doesn't always work out that way). 0 means - no compression. -1 and ``None`` both mean that the default - level of compession will be picked by the ``zlib`` module - (which is generally acceptable). - - If `interlace` is true then an interlaced image is created - (using PNG's so far only interace method, *Adam7*). This does not - affect how the pixels should be presented to the encoder, rather - it changes how they are arranged into the PNG file. On slow - connexions interlaced images can be partially decoded by the - browser to give a rough view of the image that is successively - refined as more image data appears. - - .. note :: - - Enabling the `interlace` option requires the entire image - to be processed in working memory. - - `chunk_limit` is used to limit the amount of memory used whilst - compressing the image. In order to avoid using large amounts of - memory, multiple ``IDAT`` chunks may be created. - """ - - # At the moment the `planes` argument is ignored; - # its purpose is to act as a dummy so that - # ``Writer(x, y, **info)`` works, where `info` is a dictionary - # returned by Reader.read and friends. - # Ditto for `colormap`. - - # A couple of helper functions come first. Best skipped if you - # are reading through. - - def isinteger(x): - try: - return int(x) == x - except: - return False - - def check_color(c, which): - """Checks that a colour argument for transparent or - background options is the right form. Also "corrects" bare - integers to 1-tuples. - """ - - if c is None: - return c - if greyscale: - try: - l = len(c) - except TypeError: - c = (c,) - if len(c) != 1: - raise ValueError("%s for greyscale must be 1-tuple" % - which) - if not isinteger(c[0]): - raise ValueError( - "%s colour for greyscale must be integer" % - which) - else: - if not (len(c) == 3 and - isinteger(c[0]) and - isinteger(c[1]) and - isinteger(c[2])): - raise ValueError( - "%s colour must be a triple of integers" % - which) - return c - - if size: - if len(size) != 2: - raise ValueError( - "size argument should be a pair (width, height)") - if width is not None and width != size[0]: - raise ValueError( - "size[0] (%r) and width (%r) should match when both are used." - % (size[0], width)) - if height is not None and height != size[1]: - raise ValueError( - "size[1] (%r) and height (%r) should match when both are used." - % (size[1], height)) - width,height = size - del size - - if width <= 0 or height <= 0: - raise ValueError("width and height must be greater than zero") - if not isinteger(width) or not isinteger(height): - raise ValueError("width and height must be integers") - # http://www.w3.org/TR/PNG/#7Integers-and-byte-order - if width > 2**32-1 or height > 2**32-1: - raise ValueError("width and height cannot exceed 2**32-1") - - if alpha and transparent is not None: - raise ValueError( - "transparent colour not allowed with alpha channel") - - if bytes_per_sample is not None: - warnings.warn('please use bitdepth instead of bytes_per_sample', - DeprecationWarning) - if bytes_per_sample not in (0.125, 0.25, 0.5, 1, 2): - raise ValueError( - "bytes per sample must be .125, .25, .5, 1, or 2") - bitdepth = int(8*bytes_per_sample) - del bytes_per_sample - if not isinteger(bitdepth) or bitdepth < 1 or 16 < bitdepth: - raise ValueError("bitdepth (%r) must be a postive integer <= 16" % - bitdepth) - - self.rescale = None - if palette: - if bitdepth not in (1,2,4,8): - raise ValueError("with palette, bitdepth must be 1, 2, 4, or 8") - if transparent is not None: - raise ValueError("transparent and palette not compatible") - if alpha: - raise ValueError("alpha and palette not compatible") - if greyscale: - raise ValueError("greyscale and palette not compatible") - else: - # No palette, check for sBIT chunk generation. - if alpha or not greyscale: - if bitdepth not in (8,16): - targetbitdepth = (8,16)[bitdepth > 8] - self.rescale = (bitdepth, targetbitdepth) - bitdepth = targetbitdepth - del targetbitdepth - else: - assert greyscale - assert not alpha - if bitdepth not in (1,2,4,8,16): - if bitdepth > 8: - targetbitdepth = 16 - elif bitdepth == 3: - targetbitdepth = 4 - else: - assert bitdepth in (5,6,7) - targetbitdepth = 8 - self.rescale = (bitdepth, targetbitdepth) - bitdepth = targetbitdepth - del targetbitdepth - - if bitdepth < 8 and (alpha or not greyscale and not palette): - raise ValueError( - "bitdepth < 8 only permitted with greyscale or palette") - if bitdepth > 8 and palette: - raise ValueError( - "bit depth must be 8 or less for images with palette") - - transparent = check_color(transparent, 'transparent') - background = check_color(background, 'background') - - # It's important that the true boolean values (greyscale, alpha, - # colormap, interlace) are converted to bool because Iverson's - # convention is relied upon later on. - self.width = width - self.height = height - self.transparent = transparent - self.background = background - self.gamma = gamma - self.greyscale = bool(greyscale) - self.alpha = bool(alpha) - self.colormap = bool(palette) - self.bitdepth = int(bitdepth) - self.compression = compression - self.chunk_limit = chunk_limit - self.interlace = bool(interlace) - self.palette = check_palette(palette) - - self.color_type = 4*self.alpha + 2*(not greyscale) + 1*self.colormap - assert self.color_type in (0,2,3,4,6) - - self.color_planes = (3,1)[self.greyscale or self.colormap] - self.planes = self.color_planes + self.alpha - # :todo: fix for bitdepth < 8 - self.psize = (self.bitdepth/8) * self.planes - - def make_palette(self): - """Create the byte sequences for a ``PLTE`` and if necessary a - ``tRNS`` chunk. Returned as a pair (*p*, *t*). *t* will be - ``None`` if no ``tRNS`` chunk is necessary. - """ - - p = array('B') - t = array('B') - - for x in self.palette: - p.extend(x[0:3]) - if len(x) > 3: - t.append(x[3]) - p = tostring(p) - t = tostring(t) - if t: - return p,t - return p,None - - def write(self, outfile, rows): - """Write a PNG image to the output file. `rows` should be - an iterable that yields each row in boxed row flat pixel format. - The rows should be the rows of the original image, so there - should be ``self.height`` rows of ``self.width * self.planes`` values. - If `interlace` is specified (when creating the instance), then - an interlaced PNG file will be written. Supply the rows in the - normal image order; the interlacing is carried out internally. - - .. note :: - - Interlacing will require the entire image to be in working memory. - """ - - if self.interlace: - fmt = 'BH'[self.bitdepth > 8] - a = array(fmt, itertools.chain(*rows)) - return self.write_array(outfile, a) - else: - nrows = self.write_passes(outfile, rows) - if nrows != self.height: - raise ValueError( - "rows supplied (%d) does not match height (%d)" % - (nrows, self.height)) - - def write_passes(self, outfile, rows, packed=False): - """ - Write a PNG image to the output file. - - Most users are expected to find the :meth:`write` or - :meth:`write_array` method more convenient. - - The rows should be given to this method in the order that - they appear in the output file. For straightlaced images, - this is the usual top to bottom ordering, but for interlaced - images the rows should have already been interlaced before - passing them to this function. - - `rows` should be an iterable that yields each row. When - `packed` is ``False`` the rows should be in boxed row flat pixel - format; when `packed` is ``True`` each row should be a packed - sequence of bytes. - - """ - - # http://www.w3.org/TR/PNG/#5PNG-file-signature - outfile.write(_signature) - - # http://www.w3.org/TR/PNG/#11IHDR - write_chunk(outfile, 'IHDR', - struct.pack("!2I5B", self.width, self.height, - self.bitdepth, self.color_type, - 0, 0, self.interlace)) - - # See :chunk:order - # http://www.w3.org/TR/PNG/#11gAMA - if self.gamma is not None: - write_chunk(outfile, 'gAMA', - struct.pack("!L", int(round(self.gamma*1e5)))) - - # See :chunk:order - # http://www.w3.org/TR/PNG/#11sBIT - if self.rescale: - write_chunk(outfile, 'sBIT', - struct.pack('%dB' % self.planes, - *[self.rescale[0]]*self.planes)) - - # :chunk:order: Without a palette (PLTE chunk), ordering is - # relatively relaxed. With one, gAMA chunk must precede PLTE - # chunk which must precede tRNS and bKGD. - # See http://www.w3.org/TR/PNG/#5ChunkOrdering - if self.palette: - p,t = self.make_palette() - write_chunk(outfile, 'PLTE', p) - if t: - # tRNS chunk is optional. Only needed if palette entries - # have alpha. - write_chunk(outfile, 'tRNS', t) - - # http://www.w3.org/TR/PNG/#11tRNS - if self.transparent is not None: - if self.greyscale: - write_chunk(outfile, 'tRNS', - struct.pack("!1H", *self.transparent)) - else: - write_chunk(outfile, 'tRNS', - struct.pack("!3H", *self.transparent)) - - # http://www.w3.org/TR/PNG/#11bKGD - if self.background is not None: - if self.greyscale: - write_chunk(outfile, 'bKGD', - struct.pack("!1H", *self.background)) - else: - write_chunk(outfile, 'bKGD', - struct.pack("!3H", *self.background)) - - # http://www.w3.org/TR/PNG/#11IDAT - if self.compression is not None: - compressor = zlib.compressobj(self.compression) - else: - compressor = zlib.compressobj() - - # Choose an extend function based on the bitdepth. The extend - # function packs/decomposes the pixel values into bytes and - # stuffs them onto the data array. - data = array('B') - if self.bitdepth == 8 or packed: - extend = data.extend - elif self.bitdepth == 16: - # Decompose into bytes - def extend(sl): - fmt = '!%dH' % len(sl) - data.extend(array('B', struct.pack(fmt, *sl))) - else: - # Pack into bytes - assert self.bitdepth < 8 - # samples per byte - spb = int(8/self.bitdepth) - def extend(sl): - a = array('B', sl) - # Adding padding bytes so we can group into a whole - # number of spb-tuples. - l = float(len(a)) - extra = math.ceil(l / float(spb))*spb - l - a.extend([0]*int(extra)) - # Pack into bytes - l = group(a, spb) - l = [reduce(lambda x,y: - (x << self.bitdepth) + y, e) for e in l] - data.extend(l) - if self.rescale: - oldextend = extend - factor = \ - float(2**self.rescale[1]-1) / float(2**self.rescale[0]-1) - def extend(sl): - oldextend([int(round(factor*x)) for x in sl]) - - # Build the first row, testing mostly to see if we need to - # changed the extend function to cope with NumPy integer types - # (they cause our ordinary definition of extend to fail, so we - # wrap it). See - # http://code.google.com/p/pypng/issues/detail?id=44 - enumrows = enumerate(rows) - del rows - - # First row's filter type. - data.append(0) - # :todo: Certain exceptions in the call to ``.next()`` or the - # following try would indicate no row data supplied. - # Should catch. - i,row = next(enumrows) - try: - # If this fails... - extend(row) - except: - # ... try a version that converts the values to int first. - # Not only does this work for the (slightly broken) NumPy - # types, there are probably lots of other, unknown, "nearly" - # int types it works for. - def wrapmapint(f): - return lambda sl: f(list(map(int, sl))) - extend = wrapmapint(extend) - del wrapmapint - extend(row) - - for i,row in enumrows: - # Add "None" filter type. Currently, it's essential that - # this filter type be used for every scanline as we do not - # mark the first row of a reduced pass image; that means we - # could accidentally compute the wrong filtered scanline if - # we used "up", "average", or "paeth" on such a line. - data.append(0) - extend(row) - if len(data) > self.chunk_limit: - compressed = compressor.compress(tostring(data)) - if len(compressed): - # print >> sys.stderr, len(data), len(compressed) - write_chunk(outfile, 'IDAT', compressed) - # Because of our very witty definition of ``extend``, - # above, we must re-use the same ``data`` object. Hence - # we use ``del`` to empty this one, rather than create a - # fresh one (which would be my natural FP instinct). - del data[:] - if len(data): - compressed = compressor.compress(tostring(data)) - else: - compressed = '' - flushed = compressor.flush() - if len(compressed) or len(flushed): - # print >> sys.stderr, len(data), len(compressed), len(flushed) - write_chunk(outfile, 'IDAT', compressed + flushed) - # http://www.w3.org/TR/PNG/#11IEND - write_chunk(outfile, 'IEND') - return i+1 - - def write_array(self, outfile, pixels): - """ - Write an array in flat row flat pixel format as a PNG file on - the output file. See also :meth:`write` method. - """ - - if self.interlace: - self.write_passes(outfile, self.array_scanlines_interlace(pixels)) - else: - self.write_passes(outfile, self.array_scanlines(pixels)) - - def write_packed(self, outfile, rows): - """ - Write PNG file to `outfile`. The pixel data comes from `rows` - which should be in boxed row packed format. Each row should be - a sequence of packed bytes. - - Technically, this method does work for interlaced images but it - is best avoided. For interlaced images, the rows should be - presented in the order that they appear in the file. - - This method should not be used when the source image bit depth - is not one naturally supported by PNG; the bit depth should be - 1, 2, 4, 8, or 16. - """ - - if self.rescale: - raise Error("write_packed method not suitable for bit depth %d" % - self.rescale[0]) - return self.write_passes(outfile, rows, packed=True) - - def convert_pnm(self, infile, outfile): - """ - Convert a PNM file containing raw pixel data into a PNG file - with the parameters set in the writer object. Works for - (binary) PGM, PPM, and PAM formats. - """ - - if self.interlace: - pixels = array('B') - pixels.fromfile(infile, - (self.bitdepth/8) * self.color_planes * - self.width * self.height) - self.write_passes(outfile, self.array_scanlines_interlace(pixels)) - else: - self.write_passes(outfile, self.file_scanlines(infile)) - - def convert_ppm_and_pgm(self, ppmfile, pgmfile, outfile): - """ - Convert a PPM and PGM file containing raw pixel data into a - PNG outfile with the parameters set in the writer object. - """ - pixels = array('B') - pixels.fromfile(ppmfile, - (self.bitdepth/8) * self.color_planes * - self.width * self.height) - apixels = array('B') - apixels.fromfile(pgmfile, - (self.bitdepth/8) * - self.width * self.height) - pixels = interleave_planes(pixels, apixels, - (self.bitdepth/8) * self.color_planes, - (self.bitdepth/8)) - if self.interlace: - self.write_passes(outfile, self.array_scanlines_interlace(pixels)) - else: - self.write_passes(outfile, self.array_scanlines(pixels)) - - def file_scanlines(self, infile): - """ - Generates boxed rows in flat pixel format, from the input file - `infile`. It assumes that the input file is in a "Netpbm-like" - binary format, and is positioned at the beginning of the first - pixel. The number of pixels to read is taken from the image - dimensions (`width`, `height`, `planes`) and the number of bytes - per value is implied by the image `bitdepth`. - """ - - # Values per row - vpr = self.width * self.planes - row_bytes = vpr - if self.bitdepth > 8: - assert self.bitdepth == 16 - row_bytes *= 2 - fmt = '>%dH' % vpr - def line(): - return array('H', struct.unpack(fmt, infile.read(row_bytes))) - else: - def line(): - scanline = array('B', infile.read(row_bytes)) - return scanline - for y in range(self.height): - yield line() - - def array_scanlines(self, pixels): - """ - Generates boxed rows (flat pixels) from flat rows (flat pixels) - in an array. - """ - - # Values per row - vpr = self.width * self.planes - stop = 0 - for y in range(self.height): - start = stop - stop = start + vpr - yield pixels[start:stop] - - def array_scanlines_interlace(self, pixels): - """ - Generator for interlaced scanlines from an array. `pixels` is - the full source image in flat row flat pixel format. The - generator yields each scanline of the reduced passes in turn, in - boxed row flat pixel format. - """ - - # http://www.w3.org/TR/PNG/#8InterlaceMethods - # Array type. - fmt = 'BH'[self.bitdepth > 8] - # Value per row - vpr = self.width * self.planes - for xstart, ystart, xstep, ystep in _adam7: - if xstart >= self.width: - continue - # Pixels per row (of reduced image) - ppr = int(math.ceil((self.width-xstart)/float(xstep))) - # number of values in reduced image row. - row_len = ppr*self.planes - for y in range(ystart, self.height, ystep): - if xstep == 1: - offset = y * vpr - yield pixels[offset:offset+vpr] - else: - row = array(fmt) - # There's no easier way to set the length of an array - row.extend(pixels[0:row_len]) - offset = y * vpr + xstart * self.planes - end_offset = (y+1) * vpr - skip = self.planes * xstep - for i in range(self.planes): - row[i::self.planes] = \ - pixels[offset+i:end_offset:skip] - yield row - -def write_chunk(outfile, tag, data=strtobytes('')): - """ - Write a PNG chunk to the output file, including length and - checksum. - """ - - # http://www.w3.org/TR/PNG/#5Chunk-layout - outfile.write(struct.pack("!I", len(data))) - tag = strtobytes(tag) - outfile.write(tag) - outfile.write(data) - checksum = zlib.crc32(tag) - checksum = zlib.crc32(data, checksum) - checksum &= 2**32-1 - outfile.write(struct.pack("!I", checksum)) - -def write_chunks(out, chunks): - """Create a PNG file by writing out the chunks.""" - - out.write(_signature) - for chunk in chunks: - write_chunk(out, *chunk) - -def filter_scanline(type, line, fo, prev=None): - """Apply a scanline filter to a scanline. `type` specifies the - filter type (0 to 4); `line` specifies the current (unfiltered) - scanline as a sequence of bytes; `prev` specifies the previous - (unfiltered) scanline as a sequence of bytes. `fo` specifies the - filter offset; normally this is size of a pixel in bytes (the number - of bytes per sample times the number of channels), but when this is - < 1 (for bit depths < 8) then the filter offset is 1. - """ - - assert 0 <= type < 5 - - # The output array. Which, pathetically, we extend one-byte at a - # time (fortunately this is linear). - out = array('B', [type]) - - def sub(): - ai = -fo - for x in line: - if ai >= 0: - x = (x - line[ai]) & 0xff - out.append(x) - ai += 1 - def up(): - for i,x in enumerate(line): - x = (x - prev[i]) & 0xff - out.append(x) - def average(): - ai = -fo - for i,x in enumerate(line): - if ai >= 0: - x = (x - ((line[ai] + prev[i]) >> 1)) & 0xff - else: - x = (x - (prev[i] >> 1)) & 0xff - out.append(x) - ai += 1 - def paeth(): - # http://www.w3.org/TR/PNG/#9Filter-type-4-Paeth - ai = -fo # also used for ci - for i,x in enumerate(line): - a = 0 - b = prev[i] - c = 0 - - if ai >= 0: - a = line[ai] - c = prev[ai] - p = a + b - c - pa = abs(p - a) - pb = abs(p - b) - pc = abs(p - c) - if pa <= pb and pa <= pc: Pr = a - elif pb <= pc: Pr = b - else: Pr = c - - x = (x - Pr) & 0xff - out.append(x) - ai += 1 - - if not prev: - # We're on the first line. Some of the filters can be reduced - # to simpler cases which makes handling the line "off the top" - # of the image simpler. "up" becomes "none"; "paeth" becomes - # "left" (non-trivial, but true). "average" needs to be handled - # specially. - if type == 2: # "up" - type = 0 - elif type == 3: - prev = [0]*len(line) - elif type == 4: # "paeth" - type = 1 - if type == 0: - out.extend(line) - elif type == 1: - sub() - elif type == 2: - up() - elif type == 3: - average() - else: # type == 4 - paeth() - return out - - -def from_array(a, mode=None, info={}): - """Create a PNG :class:`Image` object from a 2- or 3-dimensional array. - One application of this function is easy PIL-style saving: - ``png.from_array(pixels, 'L').save('foo.png')``. - - .. note : - - The use of the term *3-dimensional* is for marketing purposes - only. It doesn't actually work. Please bear with us. Meanwhile - enjoy the complimentary snacks (on request) and please use a - 2-dimensional array. - - Unless they are specified using the *info* parameter, the PNG's - height and width are taken from the array size. For a 3 dimensional - array the first axis is the height; the second axis is the width; - and the third axis is the channel number. Thus an RGB image that is - 16 pixels high and 8 wide will use an array that is 16x8x3. For 2 - dimensional arrays the first axis is the height, but the second axis - is ``width*channels``, so an RGB image that is 16 pixels high and 8 - wide will use a 2-dimensional array that is 16x24 (each row will be - 8*3==24 sample values). - - *mode* is a string that specifies the image colour format in a - PIL-style mode. It can be: - - ``'L'`` - greyscale (1 channel) - ``'LA'`` - greyscale with alpha (2 channel) - ``'RGB'`` - colour image (3 channel) - ``'RGBA'`` - colour image with alpha (4 channel) - - The mode string can also specify the bit depth (overriding how this - function normally derives the bit depth, see below). Appending - ``';16'`` to the mode will cause the PNG to be 16 bits per channel; - any decimal from 1 to 16 can be used to specify the bit depth. - - When a 2-dimensional array is used *mode* determines how many - channels the image has, and so allows the width to be derived from - the second array dimension. - - The array is expected to be a ``numpy`` array, but it can be any - suitable Python sequence. For example, a list of lists can be used: - ``png.from_array([[0, 255, 0], [255, 0, 255]], 'L')``. The exact - rules are: ``len(a)`` gives the first dimension, height; - ``len(a[0])`` gives the second dimension; ``len(a[0][0])`` gives the - third dimension, unless an exception is raised in which case a - 2-dimensional array is assumed. It's slightly more complicated than - that because an iterator of rows can be used, and it all still - works. Using an iterator allows data to be streamed efficiently. - - The bit depth of the PNG is normally taken from the array element's - datatype (but if *mode* specifies a bitdepth then that is used - instead). The array element's datatype is determined in a way which - is supposed to work both for ``numpy`` arrays and for Python - ``array.array`` objects. A 1 byte datatype will give a bit depth of - 8, a 2 byte datatype will give a bit depth of 16. If the datatype - does not have an implicit size, for example it is a plain Python - list of lists, as above, then a default of 8 is used. - - The *info* parameter is a dictionary that can be used to specify - metadata (in the same style as the arguments to the - :class:``png.Writer`` class). For this function the keys that are - useful are: - - height - overrides the height derived from the array dimensions and allows - *a* to be an iterable. - width - overrides the width derived from the array dimensions. - bitdepth - overrides the bit depth derived from the element datatype (but - must match *mode* if that also specifies a bit depth). - - Generally anything specified in the - *info* dictionary will override any implicit choices that this - function would otherwise make, but must match any explicit ones. - For example, if the *info* dictionary has a ``greyscale`` key then - this must be true when mode is ``'L'`` or ``'LA'`` and false when - mode is ``'RGB'`` or ``'RGBA'``. - """ - - # We abuse the *info* parameter by modifying it. Take a copy here. - # (Also typechecks *info* to some extent). - info = dict(info) - - # Syntax check mode string. - bitdepth = None - try: - mode = mode.split(';') - if len(mode) not in (1,2): - raise Error() - if mode[0] not in ('L', 'LA', 'RGB', 'RGBA'): - raise Error() - if len(mode) == 2: - try: - bitdepth = int(mode[1]) - except: - raise Error() - except Error: - raise Error("mode string should be 'RGB' or 'L;16' or similar.") - mode = mode[0] - - # Get bitdepth from *mode* if possible. - if bitdepth: - if info.get('bitdepth') and bitdepth != info['bitdepth']: - raise Error("mode bitdepth (%d) should match info bitdepth (%d)." % - (bitdepth, info['bitdepth'])) - info['bitdepth'] = bitdepth - - # Fill in and/or check entries in *info*. - # Dimensions. - if 'size' in info: - # Check width, height, size all match where used. - for dimension,axis in [('width', 0), ('height', 1)]: - if dimension in info: - if info[dimension] != info['size'][axis]: - raise Error( - "info[%r] shhould match info['size'][%r]." % - (dimension, axis)) - info['width'],info['height'] = info['size'] - if 'height' not in info: - try: - l = len(a) - except: - raise Error( - "len(a) does not work, supply info['height'] instead.") - info['height'] = l - # Colour format. - if 'greyscale' in info: - if bool(info['greyscale']) != ('L' in mode): - raise Error("info['greyscale'] should match mode.") - info['greyscale'] = 'L' in mode - if 'alpha' in info: - if bool(info['alpha']) != ('A' in mode): - raise Error("info['alpha'] should match mode.") - info['alpha'] = 'A' in mode - - planes = len(mode) - if 'planes' in info: - if info['planes'] != planes: - raise Error("info['planes'] should match mode.") - - # In order to work out whether we the array is 2D or 3D we need its - # first row, which requires that we take a copy of its iterator. - # We may also need the first row to derive width and bitdepth. - a,t = itertools.tee(a) - row = next(t) - del t - try: - row[0][0] - threed = True - testelement = row[0] - except: - threed = False - testelement = row - if 'width' not in info: - if threed: - width = len(row) - else: - width = len(row) // planes - info['width'] = width - - # Not implemented yet - assert not threed - - if 'bitdepth' not in info: - try: - dtype = testelement.dtype - # goto the "else:" clause. Sorry. - except: - try: - # Try a Python array.array. - bitdepth = 8 * testelement.itemsize - except: - # We can't determine it from the array element's - # datatype, use a default of 8. - bitdepth = 8 - else: - # If we got here without exception, we now assume that - # the array is a numpy array. - if dtype.kind == 'b': - bitdepth = 1 - else: - bitdepth = 8 * dtype.itemsize - info['bitdepth'] = bitdepth - - for thing in 'width height bitdepth greyscale alpha'.split(): - assert thing in info - return Image(a, info) - -# So that refugee's from PIL feel more at home. Not documented. -fromarray = from_array - -class Image: - """A PNG image. - You can create an :class:`Image` object from an array of pixels by calling - :meth:`png.from_array`. It can be saved to disk with the - :meth:`save` method.""" - def __init__(self, rows, info): - """ - .. note :: - - The constructor is not public. Please do not call it. - """ - - self.rows = rows - self.info = info - - def save(self, file): - """Save the image to *file*. If *file* looks like an open file - descriptor then it is used, otherwise it is treated as a - filename and a fresh file is opened. - - In general, you can only call this method once; after it has - been called the first time and the PNG image has been saved, the - source data will have been streamed, and cannot be streamed - again. - """ - - w = Writer(**self.info) - - try: - file.write - def close(): pass - except: - file = open(file, 'wb') - def close(): file.close() - - try: - w.write(file, self.rows) - finally: - close() - -class _readable: - """ - A simple file-like interface for strings and arrays. - """ - - def __init__(self, buf): - self.buf = buf - self.offset = 0 - - def read(self, n): - r = self.buf[self.offset:self.offset+n] - if isarray(r): - r = r.tostring() - self.offset += n - return r - - -class Reader: - """ - PNG decoder in pure Python. - """ - - def __init__(self, _guess=None, **kw): - """ - Create a PNG decoder object. - - The constructor expects exactly one keyword argument. If you - supply a positional argument instead, it will guess the input - type. You can choose among the following keyword arguments: - - filename - Name of input file (a PNG file). - file - A file-like object (object with a read() method). - bytes - ``array`` or ``string`` with PNG data. - - """ - if ((_guess is not None and len(kw) != 0) or - (_guess is None and len(kw) != 1)): - raise TypeError("Reader() takes exactly 1 argument") - - # Will be the first 8 bytes, later on. See validate_signature. - self.signature = None - self.transparent = None - # A pair of (len,type) if a chunk has been read but its data and - # checksum have not (in other words the file position is just - # past the 4 bytes that specify the chunk type). See preamble - # method for how this is used. - self.atchunk = None - - if _guess is not None: - if isarray(_guess): - kw["bytes"] = _guess - elif isinstance(_guess, str): - kw["filename"] = _guess - elif hasattr(_guess, 'read'): - kw["file"] = _guess - - if "filename" in kw: - self.file = open(kw["filename"], "rb") - elif "file" in kw: - self.file = kw["file"] - elif "bytes" in kw: - self.file = _readable(kw["bytes"]) - else: - raise TypeError("expecting filename, file or bytes array") - - - def chunk(self, seek=None, lenient=False): - """ - Read the next PNG chunk from the input file; returns a - (*type*,*data*) tuple. *type* is the chunk's type as a string - (all PNG chunk types are 4 characters long). *data* is the - chunk's data content, as a string. - - If the optional `seek` argument is - specified then it will keep reading chunks until it either runs - out of file or finds the type specified by the argument. Note - that in general the order of chunks in PNGs is unspecified, so - using `seek` can cause you to miss chunks. - - If the optional `lenient` argument evaluates to True, - checksum failures will raise warnings rather than exceptions. - """ - - self.validate_signature() - - while True: - # http://www.w3.org/TR/PNG/#5Chunk-layout - if not self.atchunk: - self.atchunk = self.chunklentype() - length,type = self.atchunk - self.atchunk = None - data = self.file.read(length) - if len(data) != length: - raise ChunkError('Chunk %s too short for required %i octets.' - % (type, length)) - checksum = self.file.read(4) - if len(checksum) != 4: - raise ValueError('Chunk %s too short for checksum.', tag) - if seek and type != seek: - continue - verify = zlib.crc32(strtobytes(type)) - verify = zlib.crc32(data, verify) - # Whether the output from zlib.crc32 is signed or not varies - # according to hideous implementation details, see - # http://bugs.python.org/issue1202 . - # We coerce it to be positive here (in a way which works on - # Python 2.3 and older). - verify &= 2**32 - 1 - verify = struct.pack('!I', verify) - if checksum != verify: - # print repr(checksum) - (a, ) = struct.unpack('!I', checksum) - (b, ) = struct.unpack('!I', verify) - message = "Checksum error in %s chunk: 0x%08X != 0x%08X." % (type, a, b) - if lenient: - warnings.warn(message, RuntimeWarning) - else: - raise ChunkError(message) - return type, data - - def chunks(self): - """Return an iterator that will yield each chunk as a - (*chunktype*, *content*) pair. - """ - - while True: - t,v = self.chunk() - yield t,v - if t == 'IEND': - break - - def undo_filter(self, filter_type, scanline, previous): - """Undo the filter for a scanline. `scanline` is a sequence of - bytes that does not include the initial filter type byte. - `previous` is decoded previous scanline (for straightlaced - images this is the previous pixel row, but for interlaced - images, it is the previous scanline in the reduced image, which - in general is not the previous pixel row in the final image). - When there is no previous scanline (the first row of a - straightlaced image, or the first row in one of the passes in an - interlaced image), then this argument should be ``None``. - - The scanline will have the effects of filtering removed, and the - result will be returned as a fresh sequence of bytes. - """ - - # :todo: Would it be better to update scanline in place? - # Yes, with the Cython extension making the undo_filter fast, - # updating scanline inplace makes the code 3 times faster - # (reading 50 images of 800x800 went from 40s to 16s) - result = scanline - - if filter_type == 0: - return result - - if filter_type not in (1,2,3,4): - raise FormatError('Invalid PNG Filter Type.' - ' See http://www.w3.org/TR/2003/REC-PNG-20031110/#9Filters .') - - # Filter unit. The stride from one pixel to the corresponding - # byte from the previous previous. Normally this is the pixel - # size in bytes, but when this is smaller than 1, the previous - # byte is used instead. - fu = max(1, self.psize) - - # For the first line of a pass, synthesize a dummy previous - # line. An alternative approach would be to observe that on the - # first line 'up' is the same as 'null', 'paeth' is the same - # as 'sub', with only 'average' requiring any special case. - if not previous: - previous = array('B', [0]*len(scanline)) - - def sub(): - """Undo sub filter.""" - - ai = 0 - # Loops starts at index fu. Observe that the initial part - # of the result is already filled in correctly with - # scanline. - for i in range(fu, len(result)): - x = scanline[i] - a = result[ai] - result[i] = (x + a) & 0xff - ai += 1 - - def up(): - """Undo up filter.""" - - for i in range(len(result)): - x = scanline[i] - b = previous[i] - result[i] = (x + b) & 0xff - - def average(): - """Undo average filter.""" - - ai = -fu - for i in range(len(result)): - x = scanline[i] - if ai < 0: - a = 0 - else: - a = result[ai] - b = previous[i] - result[i] = (x + ((a + b) >> 1)) & 0xff - ai += 1 - - def paeth(): - """Undo Paeth filter.""" - - # Also used for ci. - ai = -fu - for i in range(len(result)): - x = scanline[i] - if ai < 0: - a = c = 0 - else: - a = result[ai] - c = previous[ai] - b = previous[i] - p = a + b - c - pa = abs(p - a) - pb = abs(p - b) - pc = abs(p - c) - if pa <= pb and pa <= pc: - pr = a - elif pb <= pc: - pr = b - else: - pr = c - result[i] = (x + pr) & 0xff - ai += 1 - - # Call appropriate filter algorithm. Note that 0 has already - # been dealt with. - (None, - pngfilters.undo_filter_sub, - pngfilters.undo_filter_up, - pngfilters.undo_filter_average, - pngfilters.undo_filter_paeth)[filter_type](fu, scanline, previous, result) - return result - - def deinterlace(self, raw): - """ - Read raw pixel data, undo filters, deinterlace, and flatten. - Return in flat row flat pixel format. - """ - - # print >> sys.stderr, ("Reading interlaced, w=%s, r=%s, planes=%s," + - # " bpp=%s") % (self.width, self.height, self.planes, self.bps) - # Values per row (of the target image) - vpr = self.width * self.planes - - # Make a result array, and make it big enough. Interleaving - # writes to the output array randomly (well, not quite), so the - # entire output array must be in memory. - fmt = 'BH'[self.bitdepth > 8] - a = array(fmt, [0]*vpr*self.height) - source_offset = 0 - - for xstart, ystart, xstep, ystep in _adam7: - # print >> sys.stderr, "Adam7: start=%s,%s step=%s,%s" % ( - # xstart, ystart, xstep, ystep) - if xstart >= self.width: - continue - # The previous (reconstructed) scanline. None at the - # beginning of a pass to indicate that there is no previous - # line. - recon = None - # Pixels per row (reduced pass image) - ppr = int(math.ceil((self.width-xstart)/float(xstep))) - # Row size in bytes for this pass. - row_size = int(math.ceil(self.psize * ppr)) - for y in range(ystart, self.height, ystep): - filter_type = raw[source_offset] - source_offset += 1 - scanline = raw[source_offset:source_offset+row_size] - source_offset += row_size - recon = self.undo_filter(filter_type, scanline, recon) - # Convert so that there is one element per pixel value - flat = self.serialtoflat(recon, ppr) - if xstep == 1: - assert xstart == 0 - offset = y * vpr - a[offset:offset+vpr] = flat - else: - offset = y * vpr + xstart * self.planes - end_offset = (y+1) * vpr - skip = self.planes * xstep - for i in range(self.planes): - a[offset+i:end_offset:skip] = \ - flat[i::self.planes] - return a - - def iterboxed(self, rows): - """Iterator that yields each scanline in boxed row flat pixel - format. `rows` should be an iterator that yields the bytes of - each row in turn. - """ - - def asvalues(raw): - """Convert a row of raw bytes into a flat row. Result may - or may not share with argument""" - - if self.bitdepth == 8: - return raw - if self.bitdepth == 16: - raw = tostring(raw) - return array('H', struct.unpack('!%dH' % (len(raw)//2), raw)) - assert self.bitdepth < 8 - width = self.width - # Samples per byte - spb = 8//self.bitdepth - out = array('B') - mask = 2**self.bitdepth - 1 - shifts = list(map(self.bitdepth.__mul__, reversed(list(range(spb))))) - for o in raw: - out.extend([mask&(o>>i) for i in shifts]) - return out[:width] - - return map(asvalues, rows) - - def serialtoflat(self, bytes, width=None): - """Convert serial format (byte stream) pixel data to flat row - flat pixel. - """ - - if self.bitdepth == 8: - return bytes - if self.bitdepth == 16: - bytes = tostring(bytes) - return array('H', - struct.unpack('!%dH' % (len(bytes)//2), bytes)) - assert self.bitdepth < 8 - if width is None: - width = self.width - # Samples per byte - spb = 8//self.bitdepth - out = array('B') - mask = 2**self.bitdepth - 1 - shifts = list(map(self.bitdepth.__mul__, reversed(list(range(spb))))) - l = width - for o in bytes: - out.extend([(mask&(o>>s)) for s in shifts][:l]) - l -= spb - if l <= 0: - l = width - return out - - def iterstraight(self, raw): - """Iterator that undoes the effect of filtering, and yields each - row in serialised format (as a sequence of bytes). Assumes input - is straightlaced. `raw` should be an iterable that yields the - raw bytes in chunks of arbitrary size.""" - - # length of row, in bytes - rb = self.row_bytes - a = array('B') - # The previous (reconstructed) scanline. None indicates first - # line of image. - recon = None - for some in raw: - a.extend(some) - while len(a) >= rb + 1: - filter_type = a[0] - scanline = a[1:rb+1] - del a[:rb+1] - recon = self.undo_filter(filter_type, scanline, recon) - yield recon - if len(a) != 0: - # :file:format We get here with a file format error: when the - # available bytes (after decompressing) do not pack into exact - # rows. - raise FormatError( - 'Wrong size for decompressed IDAT chunk.') - assert len(a) == 0 - - def validate_signature(self): - """If signature (header) has not been read then read and - validate it; otherwise do nothing. - """ - - if self.signature: - return - self.signature = self.file.read(8) - if self.signature != _signature: - raise FormatError("PNG file has invalid signature.") - - def preamble(self, lenient=False): - """ - Extract the image metadata by reading the initial part of the PNG - file up to the start of the ``IDAT`` chunk. All the chunks that - precede the ``IDAT`` chunk are read and either processed for - metadata or discarded. - - If the optional `lenient` argument evaluates to True, - checksum failures will raise warnings rather than exceptions. - """ - - self.validate_signature() - - while True: - if not self.atchunk: - self.atchunk = self.chunklentype() - if self.atchunk is None: - raise FormatError( - 'This PNG file has no IDAT chunks.') - if self.atchunk[1] == 'IDAT': - return - self.process_chunk(lenient=lenient) - - def chunklentype(self): - """Reads just enough of the input to determine the next - chunk's length and type, returned as a (*length*, *type*) pair - where *type* is a string. If there are no more chunks, ``None`` - is returned. - """ - - x = self.file.read(8) - if not x: - return None - if len(x) != 8: - raise FormatError( - 'End of file whilst reading chunk length and type.') - length,type = struct.unpack('!I4s', x) - type = bytestostr(type) - if length > 2**31-1: - raise FormatError('Chunk %s is too large: %d.' % (type,length)) - return length,type - - def process_chunk(self, lenient=False): - """Process the next chunk and its data. This only processes the - following chunk types, all others are ignored: ``IHDR``, - ``PLTE``, ``bKGD``, ``tRNS``, ``gAMA``, ``sBIT``. - - If the optional `lenient` argument evaluates to True, - checksum failures will raise warnings rather than exceptions. - """ - - type, data = self.chunk(lenient=lenient) - if type == 'IHDR': - # http://www.w3.org/TR/PNG/#11IHDR - if len(data) != 13: - raise FormatError('IHDR chunk has incorrect length.') - (self.width, self.height, self.bitdepth, self.color_type, - self.compression, self.filter, - self.interlace) = struct.unpack("!2I5B", data) - - # Check that the header specifies only valid combinations. - if self.bitdepth not in (1,2,4,8,16): - raise Error("invalid bit depth %d" % self.bitdepth) - if self.color_type not in (0,2,3,4,6): - raise Error("invalid colour type %d" % self.color_type) - # Check indexed (palettized) images have 8 or fewer bits - # per pixel; check only indexed or greyscale images have - # fewer than 8 bits per pixel. - if ((self.color_type & 1 and self.bitdepth > 8) or - (self.bitdepth < 8 and self.color_type not in (0,3))): - raise FormatError("Illegal combination of bit depth (%d)" - " and colour type (%d)." - " See http://www.w3.org/TR/2003/REC-PNG-20031110/#table111 ." - % (self.bitdepth, self.color_type)) - if self.compression != 0: - raise Error("unknown compression method %d" % self.compression) - if self.filter != 0: - raise FormatError("Unknown filter method %d," - " see http://www.w3.org/TR/2003/REC-PNG-20031110/#9Filters ." - % self.filter) - if self.interlace not in (0,1): - raise FormatError("Unknown interlace method %d," - " see http://www.w3.org/TR/2003/REC-PNG-20031110/#8InterlaceMethods ." - % self.interlace) - - # Derived values - # http://www.w3.org/TR/PNG/#6Colour-values - colormap = bool(self.color_type & 1) - greyscale = not (self.color_type & 2) - alpha = bool(self.color_type & 4) - color_planes = (3,1)[greyscale or colormap] - planes = color_planes + alpha - - self.colormap = colormap - self.greyscale = greyscale - self.alpha = alpha - self.color_planes = color_planes - self.planes = planes - self.psize = float(self.bitdepth)/float(8) * planes - if int(self.psize) == self.psize: - self.psize = int(self.psize) - self.row_bytes = int(math.ceil(self.width * self.psize)) - # Stores PLTE chunk if present, and is used to check - # chunk ordering constraints. - self.plte = None - # Stores tRNS chunk if present, and is used to check chunk - # ordering constraints. - self.trns = None - # Stores sbit chunk if present. - self.sbit = None - elif type == 'PLTE': - # http://www.w3.org/TR/PNG/#11PLTE - if self.plte: - warnings.warn("Multiple PLTE chunks present.") - self.plte = data - if len(data) % 3 != 0: - raise FormatError( - "PLTE chunk's length should be a multiple of 3.") - if len(data) > (2**self.bitdepth)*3: - raise FormatError("PLTE chunk is too long.") - if len(data) == 0: - raise FormatError("Empty PLTE is not allowed.") - elif type == 'bKGD': - try: - if self.colormap: - if not self.plte: - warnings.warn( - "PLTE chunk is required before bKGD chunk.") - self.background = struct.unpack('B', data) - else: - self.background = struct.unpack("!%dH" % self.color_planes, - data) - except struct.error: - raise FormatError("bKGD chunk has incorrect length.") - elif type == 'tRNS': - # http://www.w3.org/TR/PNG/#11tRNS - self.trns = data - if self.colormap: - if not self.plte: - warnings.warn("PLTE chunk is required before tRNS chunk.") - else: - if len(data) > len(self.plte)/3: - # Was warning, but promoted to Error as it - # would otherwise cause pain later on. - raise FormatError("tRNS chunk is too long.") - else: - if self.alpha: - raise FormatError( - "tRNS chunk is not valid with colour type %d." % - self.color_type) - try: - self.transparent = \ - struct.unpack("!%dH" % self.color_planes, data) - except struct.error: - raise FormatError("tRNS chunk has incorrect length.") - elif type == 'gAMA': - try: - self.gamma = struct.unpack("!L", data)[0] / 100000.0 - except struct.error: - raise FormatError("gAMA chunk has incorrect length.") - elif type == 'sBIT': - self.sbit = data - if (self.colormap and len(data) != 3 or - not self.colormap and len(data) != self.planes): - raise FormatError("sBIT chunk has incorrect length.") - - def read(self, lenient=False): - """ - Read the PNG file and decode it. Returns (`width`, `height`, - `pixels`, `metadata`). - - May use excessive memory. - - `pixels` are returned in boxed row flat pixel format. - - If the optional `lenient` argument evaluates to True, - checksum failures will raise warnings rather than exceptions. - """ - - def iteridat(): - """Iterator that yields all the ``IDAT`` chunks as strings.""" - while True: - try: - type, data = self.chunk(lenient=lenient) - except ValueError as e: - raise ChunkError(e.args[0]) - if type == 'IEND': - # http://www.w3.org/TR/PNG/#11IEND - break - if type != 'IDAT': - continue - # type == 'IDAT' - # http://www.w3.org/TR/PNG/#11IDAT - if self.colormap and not self.plte: - warnings.warn("PLTE chunk is required before IDAT chunk") - yield data - - def iterdecomp(idat): - """Iterator that yields decompressed strings. `idat` should - be an iterator that yields the ``IDAT`` chunk data. - """ - - # Currently, with no max_length paramter to decompress, this - # routine will do one yield per IDAT chunk. So not very - # incremental. - d = zlib.decompressobj() - # Each IDAT chunk is passed to the decompressor, then any - # remaining state is decompressed out. - for data in idat: - # :todo: add a max_length argument here to limit output - # size. - yield array('B', d.decompress(data)) - yield array('B', d.flush()) - - self.preamble(lenient=lenient) - raw = iterdecomp(iteridat()) - - if self.interlace: - raw = array('B', itertools.chain(*raw)) - arraycode = 'BH'[self.bitdepth>8] - # Like :meth:`group` but producing an array.array object for - # each row. - pixels = map(lambda *row: array(arraycode, row), - *[iter(self.deinterlace(raw))]*self.width*self.planes) - else: - pixels = self.iterboxed(self.iterstraight(raw)) - meta = dict() - for attr in 'greyscale alpha planes bitdepth interlace'.split(): - meta[attr] = getattr(self, attr) - meta['size'] = (self.width, self.height) - for attr in 'gamma transparent background'.split(): - a = getattr(self, attr, None) - if a is not None: - meta[attr] = a - if self.plte: - meta['palette'] = self.palette() - return self.width, self.height, pixels, meta - - - def read_flat(self): - """ - Read a PNG file and decode it into flat row flat pixel format. - Returns (*width*, *height*, *pixels*, *metadata*). - - May use excessive memory. - - `pixels` are returned in flat row flat pixel format. - - See also the :meth:`read` method which returns pixels in the - more stream-friendly boxed row flat pixel format. - """ - - x, y, pixel, meta = self.read() - arraycode = 'BH'[meta['bitdepth']>8] - pixel = array(arraycode, itertools.chain(*pixel)) - return x, y, pixel, meta - - def palette(self, alpha='natural'): - """Returns a palette that is a sequence of 3-tuples or 4-tuples, - synthesizing it from the ``PLTE`` and ``tRNS`` chunks. These - chunks should have already been processed (for example, by - calling the :meth:`preamble` method). All the tuples are the - same size: 3-tuples if there is no ``tRNS`` chunk, 4-tuples when - there is a ``tRNS`` chunk. Assumes that the image is colour type - 3 and therefore a ``PLTE`` chunk is required. - - If the `alpha` argument is ``'force'`` then an alpha channel is - always added, forcing the result to be a sequence of 4-tuples. - """ - - if not self.plte: - raise FormatError( - "Required PLTE chunk is missing in colour type 3 image.") - plte = group(array('B', self.plte), 3) - if self.trns or alpha == 'force': - trns = array('B', self.trns or '') - trns.extend([255]*(len(plte)-len(trns))) - plte = list(map(operator.add, plte, group(trns, 1))) - return plte - - def asDirect(self): - """Returns the image data as a direct representation of an - ``x * y * planes`` array. This method is intended to remove the - need for callers to deal with palettes and transparency - themselves. Images with a palette (colour type 3) - are converted to RGB or RGBA; images with transparency (a - ``tRNS`` chunk) are converted to LA or RGBA as appropriate. - When returned in this format the pixel values represent the - colour value directly without needing to refer to palettes or - transparency information. - - Like the :meth:`read` method this method returns a 4-tuple: - - (*width*, *height*, *pixels*, *meta*) - - This method normally returns pixel values with the bit depth - they have in the source image, but when the source PNG has an - ``sBIT`` chunk it is inspected and can reduce the bit depth of - the result pixels; pixel values will be reduced according to - the bit depth specified in the ``sBIT`` chunk (PNG nerds should - note a single result bit depth is used for all channels; the - maximum of the ones specified in the ``sBIT`` chunk. An RGB565 - image will be rescaled to 6-bit RGB666). - - The *meta* dictionary that is returned reflects the `direct` - format and not the original source image. For example, an RGB - source image with a ``tRNS`` chunk to represent a transparent - colour, will have ``planes=3`` and ``alpha=False`` for the - source image, but the *meta* dictionary returned by this method - will have ``planes=4`` and ``alpha=True`` because an alpha - channel is synthesized and added. - - *pixels* is the pixel data in boxed row flat pixel format (just - like the :meth:`read` method). - - All the other aspects of the image data are not changed. - """ - - self.preamble() - - # Simple case, no conversion necessary. - if not self.colormap and not self.trns and not self.sbit: - return self.read() - - x,y,pixels,meta = self.read() - - if self.colormap: - meta['colormap'] = False - meta['alpha'] = bool(self.trns) - meta['bitdepth'] = 8 - meta['planes'] = 3 + bool(self.trns) - plte = self.palette() - def iterpal(pixels): - for row in pixels: - row = list(map(plte.__getitem__, row)) - yield array('B', itertools.chain(*row)) - pixels = iterpal(pixels) - elif self.trns: - # It would be nice if there was some reasonable way of doing - # this without generating a whole load of intermediate tuples. - # But tuples does seem like the easiest way, with no other way - # clearly much simpler or much faster. (Actually, the L to LA - # conversion could perhaps go faster (all those 1-tuples!), but - # I still wonder whether the code proliferation is worth it) - it = self.transparent - maxval = 2**meta['bitdepth']-1 - planes = meta['planes'] - meta['alpha'] = True - meta['planes'] += 1 - typecode = 'BH'[meta['bitdepth']>8] - def itertrns(pixels): - for row in pixels: - # For each row we group it into pixels, then form a - # characterisation vector that says whether each pixel - # is opaque or not. Then we convert True/False to - # 0/maxval (by multiplication), and add it as the extra - # channel. - row = group(row, planes) - opa = list(map(it.__ne__, row)) - opa = list(map(maxval.__mul__, opa)) - opa = list(zip(opa)) # convert to 1-tuples - yield array(typecode, - itertools.chain(*list(map(operator.add, row, opa)))) - pixels = itertrns(pixels) - targetbitdepth = None - if self.sbit: - sbit = struct.unpack('%dB' % len(self.sbit), self.sbit) - targetbitdepth = max(sbit) - if targetbitdepth > meta['bitdepth']: - raise Error('sBIT chunk %r exceeds bitdepth %d' % - (sbit,self.bitdepth)) - if min(sbit) <= 0: - raise Error('sBIT chunk %r has a 0-entry' % sbit) - if targetbitdepth == meta['bitdepth']: - targetbitdepth = None - if targetbitdepth: - shift = meta['bitdepth'] - targetbitdepth - meta['bitdepth'] = targetbitdepth - def itershift(pixels): - for row in pixels: - yield list(map(shift.__rrshift__, row)) - pixels = itershift(pixels) - return x,y,pixels,meta - - def asFloat(self, maxval=1.0): - """Return image pixels as per :meth:`asDirect` method, but scale - all pixel values to be floating point values between 0.0 and - *maxval*. - """ - - x,y,pixels,info = self.asDirect() - sourcemaxval = 2**info['bitdepth']-1 - del info['bitdepth'] - info['maxval'] = float(maxval) - factor = float(maxval)/float(sourcemaxval) - def iterfloat(): - for row in pixels: - yield list(map(factor.__mul__, row)) - return x,y,iterfloat(),info - - def _as_rescale(self, get, targetbitdepth): - """Helper used by :meth:`asRGB8` and :meth:`asRGBA8`.""" - - width,height,pixels,meta = get() - maxval = 2**meta['bitdepth'] - 1 - targetmaxval = 2**targetbitdepth - 1 - factor = float(targetmaxval) / float(maxval) - meta['bitdepth'] = targetbitdepth - def iterscale(): - for row in pixels: - yield [int(round(x*factor)) for x in row] - if maxval == targetmaxval: - return width, height, pixels, meta - else: - return width, height, iterscale(), meta - - def asRGB8(self): - """Return the image data as an RGB pixels with 8-bits per - sample. This is like the :meth:`asRGB` method except that - this method additionally rescales the values so that they - are all between 0 and 255 (8-bit). In the case where the - source image has a bit depth < 8 the transformation preserves - all the information; where the source image has bit depth - > 8, then rescaling to 8-bit values loses precision. No - dithering is performed. Like :meth:`asRGB`, an alpha channel - in the source image will raise an exception. - - This function returns a 4-tuple: - (*width*, *height*, *pixels*, *metadata*). - *width*, *height*, *metadata* are as per the :meth:`read` method. - - *pixels* is the pixel data in boxed row flat pixel format. - """ - - return self._as_rescale(self.asRGB, 8) - - def asRGBA8(self): - """Return the image data as RGBA pixels with 8-bits per - sample. This method is similar to :meth:`asRGB8` and - :meth:`asRGBA`: The result pixels have an alpha channel, *and* - values are rescaled to the range 0 to 255. The alpha channel is - synthesized if necessary (with a small speed penalty). - """ - - return self._as_rescale(self.asRGBA, 8) - - def asRGB(self): - """Return image as RGB pixels. RGB colour images are passed - through unchanged; greyscales are expanded into RGB - triplets (there is a small speed overhead for doing this). - - An alpha channel in the source image will raise an - exception. - - The return values are as for the :meth:`read` method - except that the *metadata* reflect the returned pixels, not the - source image. In particular, for this method - ``metadata['greyscale']`` will be ``False``. - """ - - width,height,pixels,meta = self.asDirect() - if meta['alpha']: - raise Error("will not convert image with alpha channel to RGB") - if not meta['greyscale']: - return width,height,pixels,meta - meta['greyscale'] = False - typecode = 'BH'[meta['bitdepth'] > 8] - def iterrgb(): - for row in pixels: - a = array(typecode, [0]) * 3 * width - for i in range(3): - a[i::3] = row - yield a - return width,height,iterrgb(),meta - - def asRGBA(self): - """Return image as RGBA pixels. Greyscales are expanded into - RGB triplets; an alpha channel is synthesized if necessary. - The return values are as for the :meth:`read` method - except that the *metadata* reflect the returned pixels, not the - source image. In particular, for this method - ``metadata['greyscale']`` will be ``False``, and - ``metadata['alpha']`` will be ``True``. - """ - - width,height,pixels,meta = self.asDirect() - if meta['alpha'] and not meta['greyscale']: - return width,height,pixels,meta - typecode = 'BH'[meta['bitdepth'] > 8] - maxval = 2**meta['bitdepth'] - 1 - maxbuffer = struct.pack('=' + typecode, maxval) * 4 * width - def newarray(): - return array(typecode, maxbuffer) - - if meta['alpha'] and meta['greyscale']: - # LA to RGBA - def convert(): - for row in pixels: - # Create a fresh target row, then copy L channel - # into first three target channels, and A channel - # into fourth channel. - a = newarray() - pngfilters.convert_la_to_rgba(row, a) - yield a - elif meta['greyscale']: - # L to RGBA - def convert(): - for row in pixels: - a = newarray() - pngfilters.convert_l_to_rgba(row, a) - yield a - else: - assert not meta['alpha'] and not meta['greyscale'] - # RGB to RGBA - def convert(): - for row in pixels: - a = newarray() - pngfilters.convert_rgb_to_rgba(row, a) - yield a - meta['alpha'] = True - meta['greyscale'] = False - return width,height,convert(),meta - - -# === Legacy Version Support === - -# :pyver:old: PyPNG works on Python versions 2.3 and 2.2, but not -# without some awkward problems. Really PyPNG works on Python 2.4 (and -# above); it works on Pythons 2.3 and 2.2 by virtue of fixing up -# problems here. It's a bit ugly (which is why it's hidden down here). -# -# Generally the strategy is one of pretending that we're running on -# Python 2.4 (or above), and patching up the library support on earlier -# versions so that it looks enough like Python 2.4. When it comes to -# Python 2.2 there is one thing we cannot patch: extended slices -# http://www.python.org/doc/2.3/whatsnew/section-slices.html. -# Instead we simply declare that features that are implemented using -# extended slices will not work on Python 2.2. -# -# In order to work on Python 2.3 we fix up a recurring annoyance involving -# the array type. In Python 2.3 an array cannot be initialised with an -# array, and it cannot be extended with a list (or other sequence). -# Both of those are repeated issues in the code. Whilst I would not -# normally tolerate this sort of behaviour, here we "shim" a replacement -# for array into place (and hope no-ones notices). You never read this. -# -# In an amusing case of warty hacks on top of warty hacks... the array -# shimming we try and do only works on Python 2.3 and above (you can't -# subclass array.array in Python 2.2). So to get it working on Python -# 2.2 we go for something much simpler and (probably) way slower. -try: - array('B').extend([]) - array('B', array('B')) -except: - # Expect to get here on Python 2.3 - try: - class _array_shim(array): - true_array = array - def __new__(cls, typecode, init=None): - super_new = super(_array_shim, cls).__new__ - it = super_new(cls, typecode) - if init is None: - return it - it.extend(init) - return it - def extend(self, extension): - super_extend = super(_array_shim, self).extend - if isinstance(extension, self.true_array): - return super_extend(extension) - if not isinstance(extension, (list, str)): - # Convert to list. Allows iterators to work. - extension = list(extension) - return super_extend(self.true_array(self.typecode, extension)) - array = _array_shim - except: - # Expect to get here on Python 2.2 - def array(typecode, init=()): - if type(init) == str: - return list(map(ord, init)) - return list(init) - -# Further hacks to get it limping along on Python 2.2 -try: - enumerate -except: - def enumerate(seq): - i=0 - for x in seq: - yield i,x - i += 1 - -try: - reversed -except: - def reversed(l): - l = list(l) - l.reverse() - for x in l: - yield x - -try: - itertools -except: - class _dummy_itertools: - pass - itertools = _dummy_itertools() - def _itertools_imap(f, seq): - for x in seq: - yield f(x) - itertools.imap = _itertools_imap - def _itertools_chain(*iterables): - for it in iterables: - for element in it: - yield element - itertools.chain = _itertools_chain - - -# === Support for users without Cython === - -try: - pngfilters -except: - class pngfilters(object): - def undo_filter_sub(filter_unit, scanline, previous, result): - """Undo sub filter.""" - - ai = 0 - # Loops starts at index fu. Observe that the initial part - # of the result is already filled in correctly with - # scanline. - for i in range(filter_unit, len(result)): - x = scanline[i] - a = result[ai] - result[i] = (x + a) & 0xff - ai += 1 - undo_filter_sub = staticmethod(undo_filter_sub) - - def undo_filter_up(filter_unit, scanline, previous, result): - """Undo up filter.""" - - for i in range(len(result)): - x = scanline[i] - b = previous[i] - result[i] = (x + b) & 0xff - undo_filter_up = staticmethod(undo_filter_up) - - def undo_filter_average(filter_unit, scanline, previous, result): - """Undo up filter.""" - - ai = -filter_unit - for i in range(len(result)): - x = scanline[i] - if ai < 0: - a = 0 - else: - a = result[ai] - b = previous[i] - result[i] = (x + ((a + b) >> 1)) & 0xff - ai += 1 - undo_filter_average = staticmethod(undo_filter_average) - - def undo_filter_paeth(filter_unit, scanline, previous, result): - """Undo Paeth filter.""" - - # Also used for ci. - ai = -filter_unit - for i in range(len(result)): - x = scanline[i] - if ai < 0: - a = c = 0 - else: - a = result[ai] - c = previous[ai] - b = previous[i] - p = a + b - c - pa = abs(p - a) - pb = abs(p - b) - pc = abs(p - c) - if pa <= pb and pa <= pc: - pr = a - elif pb <= pc: - pr = b - else: - pr = c - result[i] = (x + pr) & 0xff - ai += 1 - undo_filter_paeth = staticmethod(undo_filter_paeth) - - def convert_la_to_rgba(row, result): - for i in range(3): - result[i::4] = row[0::2] - result[3::4] = row[1::2] - convert_la_to_rgba = staticmethod(convert_la_to_rgba) - - def convert_l_to_rgba(row, result): - """Convert a grayscale image to RGBA. This method assumes the alpha - channel in result is already correctly initialized.""" - for i in range(3): - result[i::4] = row - convert_l_to_rgba = staticmethod(convert_l_to_rgba) - - def convert_rgb_to_rgba(row, result): - """Convert an RGB image to RGBA. This method assumes the alpha - channel in result is already correctly initialized.""" - for i in range(3): - result[i::4] = row[i::3] - convert_rgb_to_rgba = staticmethod(convert_rgb_to_rgba) - - -# === Internal Test Support === - -# This section comprises the tests that are internally validated (as -# opposed to tests which produce output files that are externally -# validated). Primarily they are unittests. - -# Note that it is difficult to internally validate the results of -# writing a PNG file. The only thing we can do is read it back in -# again, which merely checks consistency, not that the PNG file we -# produce is valid. - -# Run the tests from the command line: -# python -c 'import png;png.runTest()' -# If you have nose installed you can use that: -# nosetests png.py - -# (For an in-memory binary file IO object) We use BytesIO where -# available, otherwise we use StringIO, but name it BytesIO. -try: - from io import BytesIO -except: - from io import StringIO as BytesIO -import tempfile -# http://www.python.org/doc/2.4.4/lib/module-unittest.html -import unittest - - -def runTest(): - unittest.main(__name__) - -def topngbytes(name, rows, x, y, **k): - """Convenience function for creating a PNG file "in memory" as a - string. Creates a :class:`Writer` instance using the keyword arguments, - then passes `rows` to its :meth:`Writer.write` method. The resulting - PNG file is returned as a string. `name` is used to identify the file for - debugging. - """ - - import os - - print(name) - f = BytesIO() - w = Writer(x, y, **k) - w.write(f, rows) - if os.environ.get('PYPNG_TEST_TMP'): - w = open(name, 'wb') - w.write(f.getvalue()) - w.close() - return f.getvalue() - -def _redirect_io(inp, out, f): - """Calls the function `f` with ``sys.stdin`` changed to `inp` - and ``sys.stdout`` changed to `out`. They are restored when `f` - returns. This function returns whatever `f` returns. - """ - - import os - - try: - oldin,sys.stdin = sys.stdin,inp - oldout,sys.stdout = sys.stdout,out - x = f() - finally: - sys.stdin = oldin - sys.stdout = oldout - if os.environ.get('PYPNG_TEST_TMP') and hasattr(out,'getvalue'): - name = mycallersname() - if name: - w = open(name+'.png', 'wb') - w.write(out.getvalue()) - w.close() - return x - -def mycallersname(): - """Returns the name of the caller of the caller of this function - (hence the name of the caller of the function in which - "mycallersname()" textually appears). Returns None if this cannot - be determined.""" - - # http://docs.python.org/library/inspect.html#the-interpreter-stack - import inspect - - frame = inspect.currentframe() - if not frame: - return None - frame_,filename_,lineno_,funname,linelist_,listi_ = ( - inspect.getouterframes(frame)[2]) - return funname - -def seqtobytes(s): - """Convert a sequence of integers to a *bytes* instance. Good for - plastering over Python 2 / Python 3 cracks. - """ - - return strtobytes(''.join(chr(x) for x in s)) - -class Test(unittest.TestCase): - # This member is used by the superclass. If we don't define a new - # class here then when we use self.assertRaises() and the PyPNG code - # raises an assertion then we get no proper traceback. I can't work - # out why, but defining a new class here means we get a proper - # traceback. - class failureException(Exception): - pass - - def helperLN(self, n): - mask = (1 << n) - 1 - # Use small chunk_limit so that multiple chunk writing is - # tested. Making it a test for Issue 20. - w = Writer(15, 17, greyscale=True, bitdepth=n, chunk_limit=99) - f = BytesIO() - w.write_array(f, array('B', list(map(mask.__and__, list(range(1, 256)))))) - r = Reader(bytes=f.getvalue()) - x,y,pixels,meta = r.read() - self.assertEqual(x, 15) - self.assertEqual(y, 17) - self.assertEqual(list(itertools.chain(*pixels)), - list(map(mask.__and__, list(range(1,256))))) - def testL8(self): - return self.helperLN(8) - def testL4(self): - return self.helperLN(4) - def testL2(self): - "Also tests asRGB8." - w = Writer(1, 4, greyscale=True, bitdepth=2) - f = BytesIO() - w.write_array(f, array('B', list(range(4)))) - r = Reader(bytes=f.getvalue()) - x,y,pixels,meta = r.asRGB8() - self.assertEqual(x, 1) - self.assertEqual(y, 4) - for i,row in enumerate(pixels): - self.assertEqual(len(row), 3) - self.assertEqual(list(row), [0x55*i]*3) - def testP2(self): - "2-bit palette." - a = (255,255,255) - b = (200,120,120) - c = (50,99,50) - w = Writer(1, 4, bitdepth=2, palette=[a,b,c]) - f = BytesIO() - w.write_array(f, array('B', (0,1,1,2))) - r = Reader(bytes=f.getvalue()) - x,y,pixels,meta = r.asRGB8() - self.assertEqual(x, 1) - self.assertEqual(y, 4) - self.assertEqual(list(map(list, pixels)), list(map(list, [a, b, b, c]))) - def testPtrns(self): - "Test colour type 3 and tRNS chunk (and 4-bit palette)." - a = (50,99,50,50) - b = (200,120,120,80) - c = (255,255,255) - d = (200,120,120) - e = (50,99,50) - w = Writer(3, 3, bitdepth=4, palette=[a,b,c,d,e]) - f = BytesIO() - w.write_array(f, array('B', (4, 3, 2, 3, 2, 0, 2, 0, 1))) - r = Reader(bytes=f.getvalue()) - x,y,pixels,meta = r.asRGBA8() - self.assertEqual(x, 3) - self.assertEqual(y, 3) - c = c+(255,) - d = d+(255,) - e = e+(255,) - boxed = [(e,d,c),(d,c,a),(c,a,b)] - flat = [itertools.chain(*row) for row in boxed] - self.assertEqual(list(map(list, pixels)), list(map(list, flat))) - def testRGBtoRGBA(self): - "asRGBA8() on colour type 2 source.""" - # Test for Issue 26 - # Also test that Reader can take a "file-like" object. - r = Reader(BytesIO(_pngsuite['basn2c08'])) - x,y,pixels,meta = r.asRGBA8() - # Test the pixels at row 9 columns 0 and 1. - row9 = list(pixels)[9] - self.assertEqual(list(row9[0:8]), - [0xff, 0xdf, 0xff, 0xff, 0xff, 0xde, 0xff, 0xff]) - def testLtoRGBA(self): - "asRGBA() on grey source.""" - # Test for Issue 60 - r = Reader(bytes=_pngsuite['basi0g08']) - x,y,pixels,meta = r.asRGBA() - row9 = list(list(pixels)[9]) - self.assertEqual(row9[0:8], - [222, 222, 222, 255, 221, 221, 221, 255]) - def testCtrns(self): - "Test colour type 2 and tRNS chunk." - # Test for Issue 25 - r = Reader(bytes=_pngsuite['tbrn2c08']) - x,y,pixels,meta = r.asRGBA8() - # I just happen to know that the first pixel is transparent. - # In particular it should be #7f7f7f00 - row0 = list(pixels)[0] - self.assertEqual(tuple(row0[0:4]), (0x7f, 0x7f, 0x7f, 0x00)) - def testAdam7read(self): - """Adam7 interlace reading. - Specifically, test that for images in the PngSuite that - have both an interlaced and straightlaced pair that both - images from the pair produce the same array of pixels.""" - for candidate in _pngsuite: - if not candidate.startswith('basn'): - continue - candi = candidate.replace('n', 'i') - if candi not in _pngsuite: - continue - print('adam7 read', candidate) - straight = Reader(bytes=_pngsuite[candidate]) - adam7 = Reader(bytes=_pngsuite[candi]) - # Just compare the pixels. Ignore x,y (because they're - # likely to be correct?); metadata is ignored because the - # "interlace" member differs. Lame. - straight = straight.read()[2] - adam7 = adam7.read()[2] - self.assertEqual(list(map(list, straight)), list(map(list, adam7))) - def testAdam7write(self): - """Adam7 interlace writing. - For each test image in the PngSuite, write an interlaced - and a straightlaced version. Decode both, and compare results. - """ - # Not such a great test, because the only way we can check what - # we have written is to read it back again. - - for name,bytes in list(_pngsuite.items()): - # Only certain colour types supported for this test. - if name[3:5] not in ['n0', 'n2', 'n4', 'n6']: - continue - it = Reader(bytes=bytes) - x,y,pixels,meta = it.read() - pngi = topngbytes('adam7wn'+name+'.png', pixels, - x=x, y=y, bitdepth=it.bitdepth, - greyscale=it.greyscale, alpha=it.alpha, - transparent=it.transparent, - interlace=False) - x,y,ps,meta = Reader(bytes=pngi).read() - it = Reader(bytes=bytes) - x,y,pixels,meta = it.read() - pngs = topngbytes('adam7wi'+name+'.png', pixels, - x=x, y=y, bitdepth=it.bitdepth, - greyscale=it.greyscale, alpha=it.alpha, - transparent=it.transparent, - interlace=True) - x,y,pi,meta = Reader(bytes=pngs).read() - self.assertEqual(list(map(list, ps)), list(map(list, pi))) - def testPGMin(self): - """Test that the command line tool can read PGM files.""" - def do(): - return _main(['testPGMin']) - s = BytesIO() - s.write(strtobytes('P5 2 2 3\n')) - s.write(strtobytes('\x00\x01\x02\x03')) - s.flush() - s.seek(0) - o = BytesIO() - _redirect_io(s, o, do) - r = Reader(bytes=o.getvalue()) - x,y,pixels,meta = r.read() - self.assertTrue(r.greyscale) - self.assertEqual(r.bitdepth, 2) - def testPAMin(self): - """Test that the command line tool can read PAM file.""" - def do(): - return _main(['testPAMin']) - s = BytesIO() - s.write(strtobytes('P7\nWIDTH 3\nHEIGHT 1\nDEPTH 4\nMAXVAL 255\n' - 'TUPLTYPE RGB_ALPHA\nENDHDR\n')) - # The pixels in flat row flat pixel format - flat = [255,0,0,255, 0,255,0,120, 0,0,255,30] - asbytes = seqtobytes(flat) - s.write(asbytes) - s.flush() - s.seek(0) - o = BytesIO() - _redirect_io(s, o, do) - r = Reader(bytes=o.getvalue()) - x,y,pixels,meta = r.read() - self.assertTrue(r.alpha) - self.assertTrue(not r.greyscale) - self.assertEqual(list(itertools.chain(*pixels)), flat) - def testLA4(self): - """Create an LA image with bitdepth 4.""" - bytes = topngbytes('la4.png', [[5, 12]], 1, 1, - greyscale=True, alpha=True, bitdepth=4) - sbit = Reader(bytes=bytes).chunk('sBIT')[1] - self.assertEqual(sbit, strtobytes('\x04\x04')) - def testPal(self): - """Test that a palette PNG returns the palette in info.""" - r = Reader(bytes=_pngsuite['basn3p04']) - x,y,pixels,info = r.read() - self.assertEqual(x, 32) - self.assertEqual(y, 32) - self.assertTrue('palette' in info) - def testPalWrite(self): - """Test metadata for paletted PNG can be passed from one PNG - to another.""" - r = Reader(bytes=_pngsuite['basn3p04']) - x,y,pixels,info = r.read() - w = Writer(**info) - o = BytesIO() - w.write(o, pixels) - o.flush() - o.seek(0) - r = Reader(file=o) - _,_,_,again_info = r.read() - # Same palette - self.assertEqual(again_info['palette'], info['palette']) - def testPalExpand(self): - """Test that bitdepth can be used to fiddle with pallete image.""" - r = Reader(bytes=_pngsuite['basn3p04']) - x,y,pixels,info = r.read() - pixels = [list(row) for row in pixels] - info['bitdepth'] = 8 - w = Writer(**info) - o = BytesIO() - w.write(o, pixels) - o.flush() - o.seek(0) - r = Reader(file=o) - _,_,again_pixels,again_info = r.read() - # Same pixels - again_pixels = [list(row) for row in again_pixels] - self.assertEqual(again_pixels, pixels) - - def testPNMsbit(self): - """Test that PNM files can generates sBIT chunk.""" - def do(): - return _main(['testPNMsbit']) - s = BytesIO() - s.write(strtobytes('P6 8 1 1\n')) - for pixel in range(8): - s.write(struct.pack(' 0xff: - fmt = fmt + 'H' - else: - fmt = fmt + 'B' - for row in pixels: - file.write(struct.pack(fmt, *row)) - file.flush() - -def color_triple(color): - """ - Convert a command line colour value to a RGB triple of integers. - FIXME: Somewhere we need support for greyscale backgrounds etc. - """ - if color.startswith('#') and len(color) == 4: - return (int(color[1], 16), - int(color[2], 16), - int(color[3], 16)) - if color.startswith('#') and len(color) == 7: - return (int(color[1:3], 16), - int(color[3:5], 16), - int(color[5:7], 16)) - elif color.startswith('#') and len(color) == 13: - return (int(color[1:5], 16), - int(color[5:9], 16), - int(color[9:13], 16)) - -def _add_common_options(parser): - """Call *parser.add_option* for each of the options that are - common between this PNG--PNM conversion tool and the gen - tool. - """ - parser.add_option("-i", "--interlace", - default=False, action="store_true", - help="create an interlaced PNG file (Adam7)") - parser.add_option("-t", "--transparent", - action="store", type="string", metavar="#RRGGBB", - help="mark the specified colour as transparent") - parser.add_option("-b", "--background", - action="store", type="string", metavar="#RRGGBB", - help="save the specified background colour") - parser.add_option("-g", "--gamma", - action="store", type="float", metavar="value", - help="save the specified gamma value") - parser.add_option("-c", "--compression", - action="store", type="int", metavar="level", - help="zlib compression level (0-9)") - return parser - -def _main(argv): - """ - Run the PNG encoder with options from the command line. - """ - - # Parse command line arguments - from optparse import OptionParser - import re - version = '%prog ' + re.sub(r'( ?\$|URL: |Rev:)', '', __version__) - parser = OptionParser(version=version) - parser.set_usage("%prog [options] [imagefile]") - parser.add_option('-r', '--read-png', default=False, - action='store_true', - help='Read PNG, write PNM') - parser.add_option("-a", "--alpha", - action="store", type="string", metavar="pgmfile", - help="alpha channel transparency (RGBA)") - _add_common_options(parser) - - (options, args) = parser.parse_args(args=argv[1:]) - - # Convert options - if options.transparent is not None: - options.transparent = color_triple(options.transparent) - if options.background is not None: - options.background = color_triple(options.background) - - # Prepare input and output files - if len(args) == 0: - infilename = '-' - infile = sys.stdin - elif len(args) == 1: - infilename = args[0] - infile = open(infilename, 'rb') - else: - parser.error("more than one input file") - outfile = sys.stdout - if sys.platform == "win32": - import msvcrt, os - msvcrt.setmode(sys.stdout.fileno(), os.O_BINARY) - - if options.read_png: - # Encode PNG to PPM - png = Reader(file=infile) - width,height,pixels,meta = png.asDirect() - write_pnm(outfile, width, height, pixels, meta) - else: - # Encode PNM to PNG - format, width, height, depth, maxval = \ - read_pnm_header(infile, ('P5','P6','P7')) - # When it comes to the variety of input formats, we do something - # rather rude. Observe that L, LA, RGB, RGBA are the 4 colour - # types supported by PNG and that they correspond to 1, 2, 3, 4 - # channels respectively. So we use the number of channels in - # the source image to determine which one we have. We do not - # care about TUPLTYPE. - greyscale = depth <= 2 - pamalpha = depth in (2,4) - supported = [2**x-1 for x in range(1,17)] - try: - mi = supported.index(maxval) - except ValueError: - raise NotImplementedError( - 'your maxval (%s) not in supported list %s' % - (maxval, str(supported))) - bitdepth = mi+1 - writer = Writer(width, height, - greyscale=greyscale, - bitdepth=bitdepth, - interlace=options.interlace, - transparent=options.transparent, - background=options.background, - alpha=bool(pamalpha or options.alpha), - gamma=options.gamma, - compression=options.compression) - if options.alpha: - pgmfile = open(options.alpha, 'rb') - format, awidth, aheight, adepth, amaxval = \ - read_pnm_header(pgmfile, 'P5') - if amaxval != '255': - raise NotImplementedError( - 'maxval %s not supported for alpha channel' % amaxval) - if (awidth, aheight) != (width, height): - raise ValueError("alpha channel image size mismatch" - " (%s has %sx%s but %s has %sx%s)" - % (infilename, width, height, - options.alpha, awidth, aheight)) - writer.convert_ppm_and_pgm(infile, pgmfile, outfile) - else: - writer.convert_pnm(infile, outfile) - - -if __name__ == '__main__': - try: - _main(sys.argv) - except Error as e: - print(e, file=sys.stderr) diff --git a/pyqrcode/tables.py b/pyqrcode/tables.py deleted file mode 100644 index 518688c..0000000 --- a/pyqrcode/tables.py +++ /dev/null @@ -1,746 +0,0 @@ -'''This module lists out all of the tables needed to create a QR code. -If you are viewing this in the HTML documentation, I recommend reading the -actual file instead. The formating for the tables is much more readable. -''' - -#: This defines the QR Code's 'mode' which sets what -#: type of code it is along with its size. -modes = {'numeric':1, - '1':1, - 1:1, - 'alphanumeric':2, - 'alfanumeric':2, - 'text':2, - '2':2, - 2:2, - 'binary':4, - 'bin':4, - 'byte':4, - 'bytes':4, - '4':4, - 4:4, - 'japanese':8, - 'kanji':8, - '8':8, - 8:8} - -#: This defines the amount of error correction. The dictionary -#: allows the user to specify this in several ways. -error_level = {'L':'L', 'l':'L', '7%':'L', .7:'L', - 'M':'M', 'm':'M', '15%':'M', .15:'M', - 'Q':'Q', 'q':'Q', '25%':'Q', .25:'Q', - 'H':'H', 'h':'H', '30%':'H', .30:'H'} - -#: This is a dictionary holds how long the "data length" field is for -#: each version and mode of the QR Code. -data_length_field = {9:{1:10, 2:9, 4:8, 8:8}, - 26:{1:12, 2:11, 4:16, 8:10}, - 40:{1:14, 2:13, 4:16, 8:12}} - -#: QR Codes uses a unique ASCII-like table for the 'alphanumeric' mode. -#: This is a dictionary representing that unique table, where the -#: keys are the possible characters in the data and the values -#: are the character's numeric representation. -ascii_codes = {'0':0, '1':1, '2':2, '3':3, '4':4, '5':5, '6':6, '7':7, - '8':8, '9':9, 'A':10, 'B':11, 'C':12, 'D':13, 'E':14, - 'F':15, 'G':16, 'H':17, 'I':18, 'J':19, 'K':20, 'L':21, - 'M':22, 'N':23, 'O':24, 'P':25, 'Q':26, 'R':27, 'S':28, - 'T':29, 'U':30, 'V':31, 'W':32, 'X':33, 'Y':34, 'Z':35, - ' ':36, '$':37, '%':38, '*':39, '+':40, '-':41, '.':42, - '/':43, ':':44} - -#: This array specifies the size of a QR Code in pixels. These numbers are -#: defined in the standard. The indexes correspond to the QR Code's -#: version number. This array was taken from: -#: -#: http://www.denso-wave.com/qrcode/vertable1-e.html -version_size = [None, 21, 25, 29, 33, 37, 41, 45, 49, 53, 57, - 61, 65, 69, 73, 77, 81, 85, 89, 93, 97, - 101, 105, 109, 113, 117, 121, 125, 129, 133, 137, - 141, 145, 149, 153, 157, 161, 165, 169, 173, 177] - -#: This dictionary lists the data capacity for all possible QR Codes. -#: This dictionary is organized where the first key corresponds to the -#: QR Code version number. The next key corresponds to the error -#: correction level, see error. The final key corresponds to -#: the mode number, see modes. The zero mode number represents the -#: possible "data bits." This table was taken from: -#: -#: http://www.denso-wave.com/qrcode/vertable1-e.html -data_capacity = { - 1: { - "L":{0:152,1:41,2:25,4:17,8:10,}, - "M":{0:128,1:34,2:20,4:14,8:8,}, - "Q":{0:104,1:27,2:16,4:11,8:7,}, - "H":{0:72,1:17,2:10,4:7,8:4,}}, - 2: { - "L":{0:272,1:77,2:47,4:32,8:20,}, - "M":{0:224,1:63,2:38,4:26,8:16,}, - "Q":{0:176,1:48,2:29,4:20,8:12,}, - "H":{0:128,1:34,2:20,4:14,8:8,}}, - 3: { - "L":{0:440,1:127,2:77,4:53,8:32,}, - "M":{0:352,1:101,2:61,4:42,8:26,}, - "Q":{0:272,1:77,2:47,4:32,8:20,}, - "H":{0:208,1:58,2:35,4:24,8:15,}}, - 4: { - "L":{0:640,1:187,2:114,4:78,8:48,}, - "M":{0:512,1:149,2:90,4:62,8:38,}, - "Q":{0:384,1:111,2:67,4:46,8:28,}, - "H":{0:288,1:82,2:50,4:34,8:21,}}, - 5: { - "L":{0:864,1:255,2:154,4:106,8:65,}, - "M":{0:688,1:202,2:122,4:84,8:52,}, - "Q":{0:496,1:144,2:87,4:60,8:37,}, - "H":{0:368,1:106,2:64,4:44,8:27,}}, - 6: { - "L":{0:1088,1:322,2:195,4:134,8:82,}, - "M":{0:864,1:255,2:154,4:106,8:65,}, - "Q":{0:608,1:178,2:108,4:74,8:45,}, - "H":{0:480,1:139,2:84,4:58,8:36,}}, - 7: { - "L":{0:1248,1:370,2:224,4:154,8:95,}, - "M":{0:992,1:293,2:178,4:122,8:75,}, - "Q":{0:704,1:207,2:125,4:86,8:53,}, - "H":{0:528,1:154,2:93,4:64,8:39,}}, - 8: { - "L":{0:1552,1:461,2:279,4:192,8:118,}, - "M":{0:1232,1:365,2:221,4:152,8:93,}, - "Q":{0:880,1:259,2:157,4:108,8:66,}, - "H":{0:688,1:202,2:122,4:84,8:52,}}, - 9: { - "L":{0:1856,1:552,2:335,4:230,8:141,}, - "M":{0:1456,1:432,2:262,4:180,8:111,}, - "Q":{0:1056,1:312,2:189,4:130,8:80,}, - "H":{0:800,1:235,2:143,4:98,8:60,}}, - 10: { - "L":{0:2192,1:652,2:395,4:271,8:167,}, - "M":{0:1728,1:513,2:311,4:213,8:131,}, - "Q":{0:1232,1:364,2:221,4:151,8:93,}, - "H":{0:976,1:288,2:174,4:119,8:74,}}, - 11: { - "L":{0:2592,1:772,2:468,4:321,8:198,}, - "M":{0:2032,1:604,2:366,4:251,8:155,}, - "Q":{0:1440,1:427,2:259,4:177,8:109,}, - "H":{0:1120,1:331,2:200,4:137,8:85,}}, - 12: { - "L":{0:2960,1:883,2:535,4:367,8:226,}, - "M":{0:2320,1:691,2:419,4:287,8:177,}, - "Q":{0:1648,1:489,2:296,4:203,8:125,}, - "H":{0:1264,1:374,2:227,4:155,8:96,}}, - 13: { - "L":{0:3424,1:1022,2:619,4:425,8:262,}, - "M":{0:2672,1:796,2:483,4:331,8:204,}, - "Q":{0:1952,1:580,2:352,4:241,8:149,}, - "H":{0:1440,1:427,2:259,4:177,8:109,}}, - 14: { - "L":{0:3688,1:1101,2:667,4:458,8:282,}, - "M":{0:2920,1:871,2:528,4:362,8:223,}, - "Q":{0:2088,1:621,2:376,4:258,8:159,}, - "H":{0:1576,1:468,2:283,4:194,8:120,}}, - 15: { - "L":{0:4184,1:1250,2:758,4:520,8:320,}, - "M":{0:3320,1:991,2:600,4:412,8:254,}, - "Q":{0:2360,1:703,2:426,4:292,8:180,}, - "H":{0:1784,1:530,2:321,4:220,8:136,}}, - 16: { - "L":{0:4712,1:1408,2:854,4:586,8:361,}, - "M":{0:3624,1:1082,2:656,4:450,8:277,}, - "Q":{0:2600,1:775,2:470,4:322,8:198,}, - "H":{0:2024,1:602,2:365,4:250,8:154,}}, - 17: { - "L":{0:5176,1:1548,2:938,4:644,8:397,}, - "M":{0:4056,1:1212,2:734,4:504,8:310,}, - "Q":{0:2936,1:876,2:531,4:364,8:224,}, - "H":{0:2264,1:674,2:408,4:280,8:173,}}, - 18: { - "L":{0:5768,1:1725,2:1046,4:718,8:442,}, - "M":{0:4504,1:1346,2:816,4:560,8:345,}, - "Q":{0:3176,1:948,2:574,4:394,8:243,}, - "H":{0:2504,1:746,2:452,4:310,8:191,}}, - 19: { - "L":{0:6360,1:1903,2:1153,4:792,8:488,}, - "M":{0:5016,1:1500,2:909,4:624,8:384,}, - "Q":{0:3560,1:1063,2:644,4:442,8:272,}, - "H":{0:2728,1:813,2:493,4:338,8:208,}}, - 20: { - "L":{0:6888,1:2061,2:1249,4:858,8:528,}, - "M":{0:5352,1:1600,2:970,4:666,8:410,}, - "Q":{0:3880,1:1159,2:702,4:482,8:297,}, - "H":{0:3080,1:919,2:557,4:382,8:235,}}, - 21: { - "L":{0:7456,1:2232,2:1352,4:929,8:572,}, - "M":{0:5712,1:1708,2:1035,4:711,8:438,}, - "Q":{0:4096,1:1224,2:742,4:509,8:314,}, - "H":{0:3248,1:969,2:587,4:403,8:248,}}, - 22: { - "L":{0:8048,1:2409,2:1460,4:1003,8:618,}, - "M":{0:6256,1:1872,2:1134,4:779,8:480,}, - "Q":{0:4544,1:1358,2:823,4:565,8:348,}, - "H":{0:3536,1:1056,2:640,4:439,8:270,}}, - 23: { - "L":{0:8752,1:2620,2:1588,4:1091,8:672,}, - "M":{0:6880,1:2059,2:1248,4:857,8:528,}, - "Q":{0:4912,1:1468,2:890,4:611,8:376,}, - "H":{0:3712,1:1108,2:672,4:461,8:284,}}, - 24: { - "L":{0:9392,1:2812,2:1704,4:1171,8:721,}, - "M":{0:7312,1:2188,2:1326,4:911,8:561,}, - "Q":{0:5312,1:1588,2:963,4:661,8:407,}, - "H":{0:4112,1:1228,2:744,4:511,8:315,}}, - 25: { - "L":{0:10208,1:3057,2:1853,4:1273,8:784,}, - "M":{0:8000,1:2395,2:1451,4:997,8:614,}, - "Q":{0:5744,1:1718,2:1041,4:715,8:440,}, - "H":{0:4304,1:1286,2:779,4:535,8:330,}}, - 26: { - "L":{0:10960,1:3283,2:1990,4:1367,8:842,}, - "M":{0:8496,1:2544,2:1542,4:1059,8:652,}, - "Q":{0:6032,1:1804,2:1094,4:751,8:462,}, - "H":{0:4768,1:1425,2:864,4:593,8:365,}}, - 27: { - "L":{0:11744,1:3514,2:2132,4:1465,8:902,}, - "M":{0:9024,1:2701,2:1637,4:1125,8:692,}, - "Q":{0:6464,1:1933,2:1172,4:805,8:496,}, - "H":{0:5024,1:1501,2:910,4:625,8:385,}}, - 28: { - "L":{0:12248,1:3669,2:2223,4:1528,8:940,}, - "M":{0:9544,1:2857,2:1732,4:1190,8:732,}, - "Q":{0:6968,1:2085,2:1263,4:868,8:534,}, - "H":{0:5288,1:1581,2:958,4:658,8:405,}}, - 29: { - "L":{0:13048,1:3909,2:2369,4:1628,8:1002,}, - "M":{0:10136,1:3035,2:1839,4:1264,8:778,}, - "Q":{0:7288,1:2181,2:1322,4:908,8:559,}, - "H":{0:5608,1:1677,2:1016,4:698,8:430,}}, - 30: { - "L":{0:13880,1:4158,2:2520,4:1732,8:1066,}, - "M":{0:10984,1:3289,2:1994,4:1370,8:843,}, - "Q":{0:7880,1:2358,2:1429,4:982,8:604,}, - "H":{0:5960,1:1782,2:1080,4:742,8:457,}}, - 31: { - "L":{0:14744,1:4417,2:2677,4:1840,8:1132,}, - "M":{0:11640,1:3486,2:2113,4:1452,8:894,}, - "Q":{0:8264,1:2473,2:1499,4:1030,8:634,}, - "H":{0:6344,1:1897,2:1150,4:790,8:486,}}, - 32: { - "L":{0:15640,1:4686,2:2840,4:1952,8:1201,}, - "M":{0:12328,1:3693,2:2238,4:1538,8:947,}, - "Q":{0:8920,1:2670,2:1618,4:1112,8:684,}, - "H":{0:6760,1:2022,2:1226,4:842,8:518,}}, - 33: { - "L":{0:16568,1:4965,2:3009,4:2068,8:1273,}, - "M":{0:13048,1:3909,2:2369,4:1628,8:1002,}, - "Q":{0:9368,1:2805,2:1700,4:1168,8:719,}, - "H":{0:7208,1:2157,2:1307,4:898,8:553,}}, - 34: { - "L":{0:17528,1:5253,2:3183,4:2188,8:1347,}, - "M":{0:13800,1:4134,2:2506,4:1722,8:1060,}, - "Q":{0:9848,1:2949,2:1787,4:1228,8:756,}, - "H":{0:7688,1:2301,2:1394,4:958,8:590,}}, - 35: { - "L":{0:18448,1:5529,2:3351,4:2303,8:1417,}, - "M":{0:14496,1:4343,2:2632,4:1809,8:1113,}, - "Q":{0:10288,1:3081,2:1867,4:1283,8:790,}, - "H":{0:7888,1:2361,2:1431,4:983,8:605,}}, - 36: { - "L":{0:19472,1:5836,2:3537,4:2431,8:1496,}, - "M":{0:15312,1:4588,2:2780,4:1911,8:1176,}, - "Q":{0:10832,1:3244,2:1966,4:1351,8:832,}, - "H":{0:8432,1:2524,2:1530,4:1051,8:647,}}, - 37: { - "L":{0:20528,1:6153,2:3729,4:2563,8:1577,}, - "M":{0:15936,1:4775,2:2894,4:1989,8:1224,}, - "Q":{0:11408,1:3417,2:2071,4:1423,8:876,}, - "H":{0:8768,1:2625,2:1591,4:1093,8:673,}}, - 38: { - "L":{0:21616,1:6479,2:3927,4:2699,8:1661,}, - "M":{0:16816,1:5039,2:3054,4:2099,8:1292,}, - "Q":{0:12016,1:3599,2:2181,4:1499,8:923,}, - "H":{0:9136,1:2735,2:1658,4:1139,8:701,}}, - 39: { - "L":{0:22496,1:6743,2:4087,4:2809,8:1729,}, - "M":{0:17728,1:5313,2:3220,4:2213,8:1362,}, - "Q":{0:12656,1:3791,2:2298,4:1579,8:972,}, - "H":{0:9776,1:2927,2:1774,4:1219,8:750,}}, - 40: { - "L":{0:23648,1:7089,2:4296,4:2953,8:1817,}, - "M":{0:18672,1:5596,2:3391,4:2331,8:1435,}, - "Q":{0:13328,1:3993,2:2420,4:1663,8:1024,}, - "H":{0:10208,1:3057,2:1852,4:1273,8:784,}} -} - -#: This table defines the "Error Correction Code Words and Block Information." -#: The table lists the number of error correction words that are required -#: to be generated for each version and error correction level. The table -#: is accessed by first using the version number as a key and then the -#: error level. The array values correspond to these columns from the source -#: table: -#: -#: +----------------------------+ -#: |0 | EC Code Words Per Block | -#: +----------------------------+ -#: |1 | Block 1 Count | -#: +----------------------------+ -#: |2 | Block 1 Data Code Words | -#: +----------------------------+ -#: |3 | Block 2 Count | -#: +----------------------------+ -#: |4 | Block 2 Data Code Words | -#: +----------------------------+ -#: -#: This table was taken from: -#: -#: http://www.thonky.com/qr-code-tutorial/error-correction-table/ -eccwbi = { - 1: { - 'L': [ 7, 1, 19, 0, 0, ], - 'M': [10, 1, 16, 0, 0, ], - 'Q': [13, 1, 13, 0, 0, ], - 'H': [17, 1, 9, 0, 0, ], - }, - 2: { - 'L': [10, 1, 34, 0, 0, ], - 'M': [16, 1, 28, 0, 0, ], - 'Q': [22, 1, 22, 0, 0, ], - 'H': [28, 1, 16, 0, 0, ], - }, - 3: { - 'L': [15, 1, 55, 0, 0, ], - 'M': [26, 1, 44, 0, 0, ], - 'Q': [18, 2, 17, 0, 0, ], - 'H': [22, 2, 13, 0, 0, ], - }, - 4: { - 'L': [20, 1, 80, 0, 0, ], - 'M': [18, 2, 32, 0, 0, ], - 'Q': [26, 2, 24, 0, 0, ], - 'H': [16, 4, 9, 0, 0, ], - }, - 5: { - 'L': [26, 1, 108, 0, 0, ], - 'M': [24, 2, 43, 0, 0, ], - 'Q': [18, 2, 15, 2, 16, ], - 'H': [22, 2, 11, 2, 12, ], - }, - 6: { - 'L': [18, 2, 68, 0, 0, ], - 'M': [16, 4, 27, 0, 0, ], - 'Q': [24, 4, 19, 0, 0, ], - 'H': [28, 4, 15, 0, 0, ], - }, - 7: { - 'L': [20, 2, 78, 0, 0, ], - 'M': [18, 4, 31, 0, 0, ], - 'Q': [18, 2, 14, 4, 15, ], - 'H': [26, 4, 13, 1, 14, ], - }, - 8: { - 'L': [24, 2, 97, 0, 0, ], - 'M': [22, 2, 38, 2, 39, ], - 'Q': [22, 4, 18, 2, 19, ], - 'H': [26, 4, 14, 2, 15, ], - }, - 9: { - 'L': [30, 2, 116, 0, 0, ], - 'M': [22, 3, 36, 2, 37, ], - 'Q': [20, 4, 16, 4, 17, ], - 'H': [24, 4, 12, 4, 13, ], - }, - 10: { - 'L': [18, 2, 68, 2, 69, ], - 'M': [26, 4, 43, 1, 44, ], - 'Q': [24, 6, 19, 2, 20, ], - 'H': [28, 6, 15, 2, 16, ], - }, - 11: { - 'L': [20, 4, 81, 0, 0, ], - 'M': [30, 1, 50, 4, 51, ], - 'Q': [28, 4, 22, 4, 23, ], - 'H': [24, 3, 12, 8, 13, ], - }, - 12: { - 'L': [24, 2, 92, 2, 93, ], - 'M': [22, 6, 36, 2, 37, ], - 'Q': [26, 4, 20, 6, 21, ], - 'H': [28, 7, 14, 4, 15, ], - }, - 13: { - 'L': [26, 4, 107, 0, 0, ], - 'M': [22, 8, 37, 1, 38, ], - 'Q': [24, 8, 20, 4, 21, ], - 'H': [22, 12, 11, 4, 12, ], - }, - 14: { - 'L': [30, 3, 115, 1, 116, ], - 'M': [24, 4, 40, 5, 41, ], - 'Q': [20, 11, 16, 5, 17, ], - 'H': [24, 11, 12, 5, 13, ], - }, - 15: { - 'L': [22, 5, 87, 1, 88, ], - 'M': [24, 5, 41, 5, 42, ], - 'Q': [30, 5, 24, 7, 25, ], - 'H': [24, 11, 12, 7, 13, ], - }, - 16: { - 'L': [24, 5, 98, 1, 99, ], - 'M': [28, 7, 45, 3, 46, ], - 'Q': [24, 15, 19, 2, 20, ], - 'H': [30, 3, 15, 13, 16, ], - }, - 17: { - 'L': [28, 1, 107, 5, 108, ], - 'M': [28, 10, 46, 1, 47, ], - 'Q': [28, 1, 22, 15, 23, ], - 'H': [28, 2, 14, 17, 15, ], - }, - 18: { - 'L': [30, 5, 120, 1, 121, ], - 'M': [26, 9, 43, 4, 44, ], - 'Q': [28, 17, 22, 1, 23, ], - 'H': [28, 2, 14, 19, 15, ], - }, - 19: { - 'L': [28, 3, 113, 4, 114, ], - 'M': [26, 3, 44, 11, 45, ], - 'Q': [26, 17, 21, 4, 22, ], - 'H': [26, 9, 13, 16, 14, ], - }, - 20: { - 'L': [28, 3, 107, 5, 108, ], - 'M': [26, 3, 41, 13, 42, ], - 'Q': [30, 15, 24, 5, 25, ], - 'H': [28, 15, 15, 10, 16, ], - }, - 21: { - 'L': [28, 4, 116, 4, 117, ], - 'M': [26, 17, 42, 0, 0, ], - 'Q': [28, 17, 22, 6, 23, ], - 'H': [30, 19, 16, 6, 17, ], - }, - 22: { - 'L': [28, 2, 111, 7, 112, ], - 'M': [28, 17, 46, 0, 0, ], - 'Q': [30, 7, 24, 16, 25, ], - 'H': [24, 34, 13, 0, 0, ], - }, - 23: { - 'L': [30, 4, 121, 5, 122, ], - 'M': [28, 4, 47, 14, 48, ], - 'Q': [30, 11, 24, 14, 25, ], - 'H': [30, 16, 15, 14, 16, ], - }, - 24: { - 'L': [30, 6, 117, 4, 118, ], - 'M': [28, 6, 45, 14, 46, ], - 'Q': [30, 11, 24, 16, 25, ], - 'H': [30, 30, 16, 2, 17, ], - }, - 25: { - 'L': [26, 8, 106, 4, 107, ], - 'M': [28, 8, 47, 13, 48, ], - 'Q': [30, 7, 24, 22, 25, ], - 'H': [30, 22, 15, 13, 16, ], - }, - 26: { - 'L': [28, 10, 114, 2, 115, ], - 'M': [28, 19, 46, 4, 47, ], - 'Q': [28, 28, 22, 6, 23, ], - 'H': [30, 33, 16, 4, 17, ], - }, - 27: { - 'L': [30, 8, 122, 4, 123, ], - 'M': [28, 22, 45, 3, 46, ], - 'Q': [30, 8, 23, 26, 24, ], - 'H': [30, 12, 15, 28, 16, ], - }, - 28: { - 'L': [30, 3, 117, 10, 118, ], - 'M': [28, 3, 45, 23, 46, ], - 'Q': [30, 4, 24, 31, 25, ], - 'H': [30, 11, 15, 31, 16, ], - }, - 29: { - 'L': [30, 7, 116, 7, 117, ], - 'M': [28, 21, 45, 7, 46, ], - 'Q': [30, 1, 23, 37, 24, ], - 'H': [30, 19, 15, 26, 16, ], - }, - 30: { - 'L': [30, 5, 115, 10, 116, ], - 'M': [28, 19, 47, 10, 48, ], - 'Q': [30, 15, 24, 25, 25, ], - 'H': [30, 23, 15, 25, 16, ], - }, - 31: { - 'L': [30, 13, 115, 3, 116, ], - 'M': [28, 2, 46, 29, 47, ], - 'Q': [30, 42, 24, 1, 25, ], - 'H': [30, 23, 15, 28, 16, ], - }, - 32: { - 'L': [30, 17, 115, 0, 0, ], - 'M': [28, 10, 46, 23, 47, ], - 'Q': [30, 10, 24, 35, 25, ], - 'H': [30, 19, 15, 35, 16, ], - }, - 33: { - 'L': [30, 17, 115, 1, 116, ], - 'M': [28, 14, 46, 21, 47, ], - 'Q': [30, 29, 24, 19, 25, ], - 'H': [30, 11, 15, 46, 16, ], - }, - 34: { - 'L': [30, 13, 115, 6, 116, ], - 'M': [28, 14, 46, 23, 47, ], - 'Q': [30, 44, 24, 7, 25, ], - 'H': [30, 59, 16, 1, 17, ], - }, - 35: { - 'L': [30, 12, 121, 7, 122, ], - 'M': [28, 12, 47, 26, 48, ], - 'Q': [30, 39, 24, 14, 25, ], - 'H': [30, 22, 15, 41, 16, ], - }, - 36: { - 'L': [30, 6, 121, 14, 122, ], - 'M': [28, 6, 47, 34, 48, ], - 'Q': [30, 46, 24, 10, 25, ], - 'H': [30, 2, 15, 64, 16, ], - }, - 37: { - 'L': [30, 17, 122, 4, 123, ], - 'M': [28, 29, 46, 14, 47, ], - 'Q': [30, 49, 24, 10, 25, ], - 'H': [30, 24, 15, 46, 16, ], - }, - 38: { - 'L': [30, 4, 122, 18, 123, ], - 'M': [28, 13, 46, 32, 47, ], - 'Q': [30, 48, 24, 14, 25, ], - 'H': [30, 42, 15, 32, 16, ], - }, - 39: { - 'L': [30, 20, 117, 4, 118, ], - 'M': [28, 40, 47, 7, 48, ], - 'Q': [30, 43, 24, 22, 25, ], - 'H': [30, 10, 15, 67, 16, ], - }, - 40: { - 'L': [30, 19, 118, 6, 119, ], - 'M': [28, 18, 47, 31, 48, ], - 'Q': [30, 34, 24, 34, 25, ], - 'H': [30, 20, 15, 61, 16, ], - }, -} - -#: This table lists all of the generator polynomials used by QR Codes. -#: They are indexed by the number of "ECC Code Words" (see table above). -#: This table is taken from: -#: -#: http://www.matchadesign.com/blog/qr-code-demystified-part-4/ -generator_polynomials = { - 7:[87, 229, 146, 149, 238, 102, 21], - 10:[251, 67, 46, 61, 118, 70, 64, 94, 32, 45], - 13:[74, 152, 176, 100, 86, 100, 106, 104, 130, 218, 206, 140, 78], - 15:[8, 183, 61, 91, 202, 37, 51, 58, 58, 237, 140, 124, 5, 99, 105], - 16:[120, 104, 107, 109, 102, 161, 76, 3, 91, 191, 147, 169, 182, 194, - 225, 120], - 17:[43, 139, 206, 78, 43, 239, 123, 206, 214, 147, 24, 99, 150, 39, - 243, 163, 136], - 18:[215, 234, 158, 94, 184, 97, 118, 170, 79, 187, 152, 148, 252, 179, - 5, 98, 96, 153], - 20:[17, 60, 79, 50, 61, 163, 26, 187, 202, 180, 221, 225, 83, 239, 156, - 164, 212, 212, 188, 190], - 22:[210, 171, 247, 242, 93, 230, 14, 109, 221, 53, 200, 74, 8, 172, 98, - 80, 219, 134, 160, 105, 165, 231], - 24:[229, 121, 135, 48, 211, 117, 251, 126, 159, 180, 169, 152, 192, 226, - 228, 218, 111, 0, 117, 232, 87, 96, 227, 21], - 26:[173, 125, 158, 2, 103, 182, 118, 17, 145, 201, 111, 28, 165, 53, 161, - 21, 245, 142, 13, 102, 48, 227, 153, 145, 218, 70], - 28:[168, 223, 200, 104, 224, 234, 108, 180, 110, 190, 195, 147, 205, 27, - 232, 201, 21, 43, 245, 87, 42, 195, 212, 119, 242, 37, 9, 123], - 30:[41, 173, 145, 152, 216, 31, 179, 182, 50, 48, 110, 86, 239, 96, 222, - 125, 42, 173, 226, 193, 224, 130, 156, 37, 251, 216, 238, 40, 192, - 180] -} - -#: This table contains the log and values used in GF(256) arithmetic. -#: They are used to generate error correction codes for QR Codes. -#: This table is taken from: -#: -#: vhttp://www.thonky.com/qr-code-tutorial/log-antilog-table/ -galois_log = [ - 1, 2, 4, 8, 16, 32, 64, 128, 29, 58, 116, 232, 205, 135, 19, 38, 76, 152, - 45, 90, 180, 117, 234, 201, 143, 3, 6, 12, 24, 48, 96, 192, 157, 39, 78, - 156, 37, 74, 148, 53, 106, 212, 181, 119, 238, 193, 159, 35, 70, 140, 5, - 10, 20, 40, 80, 160, 93, 186, 105, 210, 185, 111, 222, 161, 95, 190, 97, - 194, 153, 47, 94, 188, 101, 202, 137, 15, 30, 60, 120, 240, 253, 231, 211, - 187, 107, 214, 177, 127, 254, 225, 223, 163, 91, 182, 113, 226, 217, 175, - 67, 134, 17, 34, 68, 136, 13, 26, 52, 104, 208, 189, 103, 206, 129, 31, - 62, 124, 248, 237, 199, 147, 59, 118, 236, 197, 151, 51, 102, 204, 133, - 23, 46, 92, 184, 109, 218, 169, 79, 158, 33, 66, 132, 21, 42, 84, 168, 77, - 154, 41, 82, 164, 85, 170, 73, 146, 57, 114, 228, 213, 183, 115, 230, 209, - 191, 99, 198, 145, 63, 126, 252, 229, 215, 179, 123, 246, 241, 255, 227, - 219, 171, 75, 150, 49, 98, 196, 149, 55, 110, 220, 165, 87, 174, 65, 130, - 25, 50, 100, 200, 141, 7, 14, 28, 56, 112, 224, 221, 167, 83, 166, 81, - 162, 89, 178, 121, 242, 249, 239, 195, 155, 43, 86, 172, 69, 138, 9, 18, - 36, 72, 144, 61, 122, 244, 245, 247, 243, 251, 235, 203, 139, 11, 22, 44, - 88, 176, 125, 250, 233, 207, 131, 27, 54, 108, 216, 173, 71, 142, 1,] - -#: This table contains the antilog and values used in GF(256) arithmetic. -#: They are used to generate error correction codes for QR Codes. -#: This table is taken from: -#: -#: http://www.thonky.com/qr-code-tutorial/log-antilog-table/ -galois_antilog = [ - None, 0, 1, 25, 2, 50, 26, 198, 3, 223, 51, 238, 27, 104, 199, 75, 4, 100, - 224, 14, 52, 141, 239, 129, 28, 193, 105, 248, 200, 8, 76, 113, 5, 138, - 101, 47, 225, 36, 15, 33, 53, 147, 142, 218, 240, 18, 130, 69, 29, 181, - 194, 125, 106, 39, 249, 185, 201, 154, 9, 120, 77, 228, 114, 166, 6, 191, - 139, 98, 102, 221, 48, 253, 226, 152, 37, 179, 16, 145, 34, 136, 54, 208, - 148, 206, 143, 150, 219, 189, 241, 210, 19, 92, 131, 56, 70, 64, 30, 66, - 182, 163, 195, 72, 126, 110, 107, 58, 40, 84, 250, 133, 186, 61, 202, 94, - 155, 159, 10, 21, 121, 43, 78, 212, 229, 172, 115, 243, 167, 87, 7, 112, - 192, 247, 140, 128, 99, 13, 103, 74, 222, 237, 49, 197, 254, 24, 227, 165, - 153, 119, 38, 184, 180, 124, 17, 68, 146, 217, 35, 32, 137, 46, 55, 63, - 209, 91, 149, 188, 207, 205, 144, 135, 151, 178, 220, 252, 190, 97, 242, - 86, 211, 171, 20, 42, 93, 158, 132, 60, 57, 83, 71, 109, 65, 162, 31, 45, - 67, 216, 183, 123, 164, 118, 196, 23, 73, 236, 127, 12, 111, 246, 108, - 161, 59, 82, 41, 157, 85, 170, 251, 96, 134, 177, 187, 204, 62, 90, 203, - 89, 95, 176, 156, 169, 160, 81, 11, 245, 22, 235, 122, 117, 44, 215, 79, - 174, 213, 233, 230, 231, 173, 232, 116, 214, 244, 234, 168, 80, 88, 175,] - -#: This table contains the coordinates for the position adjustment patterns. -#: The index of the table corresponds to the QR Code's version number. -#: This table is taken from: -#: -#: http://www.thonky.com/qr-code-tutorial/part-3-mask-pattern/ -position_adjustment = [ - None, #There is not version 0 - None, #Version 1 does not need adjustment - [6, 18, ], - [6, 22, ], - [6, 26, ], - [6, 30, ], - [6, 34, ], - [6, 22, 38, ], - [6, 24, 42, ], - [6, 26, 46, ], - [6, 28, 50, ], - [6, 30, 54, ], - [6, 32, 58, ], - [6, 34, 62, ], - [6, 26, 46, 66, ], - [6, 26, 48, 70, ], - [6, 26, 50, 74, ], - [6, 30, 54, 78, ], - [6, 30, 56, 82, ], - [6, 30, 58, 86, ], - [6, 34, 62, 90, ], - [6, 28, 50, 72, 94, ], - [6, 26, 50, 74, 98, ], - [6, 30, 54, 78, 102, ], - [6, 28, 54, 80, 106, ], - [6, 32, 58, 84, 110, ], - [6, 30, 58, 86, 114, ], - [6, 34, 62, 90, 118, ], - [6, 26, 50, 74, 98, 122, ], - [6, 30, 54, 78, 102, 126, ], - [6, 26, 52, 78, 104, 130, ], - [6, 30, 56, 82, 108, 134, ], - [6, 34, 60, 86, 112, 138, ], - [6, 30, 58, 86, 114, 142, ], - [6, 34, 62, 90, 118, 146, ], - [6, 30, 54, 78, 102, 126, 150, ], - [6, 24, 50, 76, 102, 128, 154, ], - [6, 28, 54, 80, 106, 132, 158, ], - [6, 32, 58, 84, 110, 136, 162, ], - [6, 26, 54, 82, 110, 138, 166, ], - [6, 30, 58, 86, 114, 142, 170, ], -] - -#: This table specifies the bit pattern to be added to a QR Code's -#: image to specify what version the code is. Note, this pattern -#: is not used for versions 1-6. This table is taken from: -#: -#: http://www.thonky.com/qr-code-tutorial/part-3-mask-pattern/ -version_pattern = [None,None,None,None,None,None,None, #0-6 - '000111110010010100', '001000010110111100', '001001101010011001', - '001010010011010011', '001011101111110110', '001100011101100010', - '001101100001000111', '001110011000001101', '001111100100101000', - '010000101101111000', '010001010001011101', '010010101000010111', - '010011010100110010', '010100100110100110', '010101011010000011', - '010110100011001001', '010111011111101100', '011000111011000100', - '011001000111100001', '011010111110101011', '011011000010001110', - '011100110000011010', '011101001100111111', '011110110101110101', - '011111001001010000', '100000100111010101', '100001011011110000', - '100010100010111010', '100011011110011111', '100100101100001011', - '100101010000101110', '100110101001100100', '100111010101000001', - '101000110001101001' -] - -#: This table contains the bit fields needed to specify the error code level and -#: mask pattern used by a QR Code. This table is take from: -#: -#: http://www.thonky.com/qr-code-tutorial/part-3-mask-pattern/ -type_bits = { - 'L':{ - 0:'111011111000100', - 1:'111001011110011', - 2:'111110110101010', - 3:'111100010011101', - 4:'110011000101111', - 5:'110001100011000', - 6:'110110001000001', - 7:'110100101110110', - }, - 'M':{ - 0:'101010000010010', - 1:'101000100100101', - 2:'101111001111100', - 3:'101101101001011', - 4:'100010111111001', - 5:'100000011001110', - 6:'100111110010111', - 7:'100101010100000', - }, - 'Q':{ - 0:'011010101011111', - 1:'011000001101000', - 2:'011111100110001', - 3:'011101000000110', - 4:'010010010110100', - 5:'010000110000011', - 6:'010111011011010', - 7:'010101111101101', - }, - 'H':{ - 0:'001011010001001', - 1:'001001110111110', - 2:'001110011100111', - 3:'001100111010000', - 4:'000011101100010', - 5:'000001001010101', - 6:'000110100001100', - 7:'000100000111011', - }, -} - -#: This table contains *functions* to compute whether to change current bit when -#: creating the masks. All of the functions in the table return a boolean value. -#: A True result means you should add the bit to the QR Code exactly as is. A -#: False result means you should add the opposite bit. This table was taken -#: from: -#: -#: http://www.thonky.com/qr-code-tutorial/mask-patterns/ -mask_patterns = [ - lambda row,col: (row + col) % 2 == 0, - lambda row,col: row % 2 == 0, - lambda row,col: col % 3 == 0, - lambda row,col: (row + col) % 3 == 0, - lambda row,col: ((row // 2) + (col // 3)) % 2 == 0, - lambda row,col: ((row * col) % 2) + ((row * col) % 3) == 0, - lambda row,col: (((row * col) % 2) + ((row * col) % 3)) % 2 == 0, - lambda row,col: (((row + col) % 2) + ((row * col) % 3)) % 2 == 0] diff --git a/pysimplesoap/__init__.py b/pysimplesoap/__init__.py deleted file mode 100644 index 271cba3..0000000 --- a/pysimplesoap/__init__.py +++ /dev/null @@ -1,16 +0,0 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- - -"""PySimpleSOAP""" - - -__author__ = "Mariano Reingart" -__author_email__ = "reingart@gmail.com" -__copyright__ = "Copyright (C) 2013 Mariano Reingart" -__license__ = "LGPL 3.0" -__version__ = "1.11" - -TIMEOUT = 30 - - -from . import client, server, simplexml, transport diff --git a/pysimplesoap/client.py b/pysimplesoap/client.py deleted file mode 100644 index ae06884..0000000 --- a/pysimplesoap/client.py +++ /dev/null @@ -1,713 +0,0 @@ -#!/usr/bin/python -# -*- coding: utf-8 -*- -# This program is free software; you can redistribute it and/or modify -# it under the terms of the GNU Lesser General Public License as published by the -# Free Software Foundation; either version 3, or (at your option) any later -# version. -# -# This program is distributed in the hope that it will be useful, but -# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTIBILITY -# or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License -# for more details. - -"""Pythonic simple SOAP Client implementation""" - -from __future__ import unicode_literals -import sys -if sys.version > '3': - unicode = str - -try: - import cPickle as pickle -except ImportError: - import pickle -import hashlib -import logging -import os -import tempfile - -from . import __author__, __copyright__, __license__, __version__, TIMEOUT -from .simplexml import SimpleXMLElement, TYPE_MAP, REVERSE_TYPE_MAP, OrderedDict -from .transport import get_http_wrapper, set_http_wrapper, get_Http -# Utility functions used throughout wsdl_parse, moved aside for readability -from .helpers import fetch, sort_dict, make_key, process_element, \ - postprocess_element, get_message, preprocess_schema, \ - get_local_name, get_namespace_prefix, TYPE_MAP - - -log = logging.getLogger(__name__) - - -class SoapFault(RuntimeError): - def __init__(self, faultcode, faultstring): - self.faultcode = faultcode - self.faultstring = faultstring - RuntimeError.__init__(self, faultcode, faultstring) - - def __str__(self): - return self.__unicode__().encode('ascii', 'ignore') - - def __unicode__(self): - return '%s: %s' % (self.faultcode, self.faultstring) - - def __repr__(self): - return "SoapFault(%s, %s)" % (repr(self.faultcode), - repr(self.faultstring)) - - -# soap protocol specification & namespace -soap_namespaces = dict( - soap11='http://schemas.xmlsoap.org/soap/envelope/', - soap='http://schemas.xmlsoap.org/soap/envelope/', - soapenv='http://schemas.xmlsoap.org/soap/envelope/', - soap12='http://www.w3.org/2003/05/soap-env', - soap12env="http://www.w3.org/2003/05/soap-envelope", -) - - -class SoapClient(object): - """Simple SOAP Client (simil PHP)""" - def __init__(self, location=None, action=None, namespace=None, - cert=None, exceptions=True, proxy=None, ns=None, - soap_ns=None, wsdl=None, wsdl_basedir='', cache=False, cacert=None, - sessions=False, soap_server=None, timeout=TIMEOUT, - http_headers=None, trace=False, - ): - """ - :param http_headers: Additional HTTP Headers; example: {'Host': 'ipsec.example.com'} - """ - self.certssl = cert - self.keyssl = None - self.location = location # server location (url) - self.action = action # SOAP base action - self.namespace = namespace # message - self.exceptions = exceptions # lanzar execpiones? (Soap Faults) - self.xml_request = self.xml_response = '' - self.http_headers = http_headers or {} - self.wsdl_basedir = wsdl_basedir - - # shortcut to print all debugging info and sent / received xml messages - if trace: - logging.basicConfig(level=logging.DEBUG) - - if not soap_ns and not ns: - self.__soap_ns = 'soap' # 1.1 - elif not soap_ns and ns: - self.__soap_ns = 'soapenv' # 1.2 - else: - self.__soap_ns = soap_ns - - # SOAP Server (special cases like oracle, jbossas6 or jetty) - self.__soap_server = soap_server - - # SOAP Header support - self.__headers = {} # general headers - self.__call_headers = None # OrderedDict to be marshalled for RPC Call - - # check if the Certification Authority Cert is a string and store it - if cacert and cacert.startswith('-----BEGIN CERTIFICATE-----'): - fd, filename = tempfile.mkstemp() - f = os.fdopen(fd, 'w+b', -1) - log.debug("Saving CA certificate to %s" % filename) - f.write(cacert) - cacert = filename - f.close() - self.cacert = cacert - - # Create HTTP wrapper - Http = get_Http() - self.http = Http(timeout=timeout, cacert=cacert, proxy=proxy, sessions=sessions) - - # namespace prefix, None to use xmlns attribute or False to not use it: - self.__ns = ns - if not ns: - self.__xml = """ -<%(soap_ns)s:Envelope xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" - xmlns:xsd="http://www.w3.org/2001/XMLSchema" - xmlns:%(soap_ns)s="%(soap_uri)s"> -<%(soap_ns)s:Header/> -<%(soap_ns)s:Body> - <%(method)s xmlns="%(namespace)s"> - - -""" - else: - self.__xml = """ -<%(soap_ns)s:Envelope xmlns:%(soap_ns)s="%(soap_uri)s" xmlns:%(ns)s="%(namespace)s"> -<%(soap_ns)s:Header/> -<%(soap_ns)s:Body> - <%(ns)s:%(method)s> - - -""" - - # parse wsdl url - self.services = wsdl and self.wsdl_parse(wsdl, cache=cache) - self.service_port = None # service port for late binding - - def __getattr__(self, attr): - """Return a pseudo-method that can be called""" - if not self.services: # not using WSDL? - return lambda self=self, *args, **kwargs: self.call(attr, *args, **kwargs) - else: # using WSDL: - return lambda *args, **kwargs: self.wsdl_call(attr, *args, **kwargs) - - def call(self, method, *args, **kwargs): - """Prepare xml request and make SOAP call, returning a SimpleXMLElement. - - If a keyword argument called "headers" is passed with a value of a - SimpleXMLElement object, then these headers will be inserted into the - request. - """ - #TODO: method != input_message - # Basic SOAP request: - xml = self.__xml % dict(method=method, # method tag name - namespace=self.namespace, # method ns uri - ns=self.__ns, # method ns prefix - soap_ns=self.__soap_ns, # soap prefix & uri - soap_uri=soap_namespaces[self.__soap_ns]) - request = SimpleXMLElement(xml, namespace=self.__ns and self.namespace, - prefix=self.__ns) - - request_headers = kwargs.pop('headers', None) - - # serialize parameters - if kwargs: - parameters = list(kwargs.items()) - else: - parameters = args - if parameters and isinstance(parameters[0], SimpleXMLElement): - # merge xmlelement parameter ("raw" - already marshalled) - if parameters[0].children() is not None: - for param in parameters[0].children(): - getattr(request, method).import_node(param) - elif parameters: - # marshall parameters: - use_ns = None if self.__soap_server == "jetty" else True - for k, v in parameters: # dict: tag=valor - getattr(request, method).marshall(k, v, ns=use_ns) - elif not self.__soap_server in ('oracle',) or self.__soap_server in ('jbossas6',): - # JBossAS-6 requires no empty method parameters! - delattr(request("Body", ns=list(soap_namespaces.values()),), method) - - # construct header and parameters (if not wsdl given) except wsse - if self.__headers and not self.services: - self.__call_headers = dict([(k, v) for k, v in self.__headers.items() - if not k.startswith('wsse:')]) - # always extract WS Security header and send it - if 'wsse:Security' in self.__headers: - #TODO: namespaces too hardwired, clean-up... - header = request('Header', ns=list(soap_namespaces.values()),) - k = 'wsse:Security' - v = self.__headers[k] - header.marshall(k, v, ns=False, add_children_ns=False) - header(k)['xmlns:wsse'] = 'http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd' - # - if self.__call_headers: - header = request('Header', ns=list(soap_namespaces.values()),) - for k, v in self.__call_headers.items(): - ##if not self.__ns: - ## header['xmlns'] - if isinstance(v, SimpleXMLElement): - # allows a SimpleXMLElement to be constructed and inserted - # rather than a dictionary. marshall doesn't allow ns: prefixes - # in dict key names - header.import_node(v) - else: - header.marshall(k, v, ns=self.__ns, add_children_ns=False) - if request_headers: - header = request('Header', ns=list(soap_namespaces.values()),) - for subheader in request_headers.children(): - header.import_node(subheader) - - self.xml_request = request.as_xml() - self.xml_response = self.send(method, self.xml_request) - response = SimpleXMLElement(self.xml_response, namespace=self.namespace, - jetty=self.__soap_server in ('jetty',)) - if self.exceptions and response("Fault", ns=list(soap_namespaces.values()), error=False): - raise SoapFault(unicode(response.faultcode), unicode(response.faultstring)) - return response - - def send(self, method, xml): - """Send SOAP request using HTTP""" - if self.location == 'test': return - # location = '%s' % self.location #?op=%s" % (self.location, method) - location = str(self.location) - - if self.services: - soap_action = str(self.action) - else: - soap_action = str(self.action) + method - - headers = { - 'Content-type': 'text/xml; charset="UTF-8"', - 'Content-length': str(len(xml)), - 'SOAPAction': '"%s"' % soap_action - } - headers.update(self.http_headers) - log.info("POST %s" % location) - log.debug('\n'.join(["%s: %s" % (k, v) for k, v in headers.items()])) - log.debug(xml) - - response, content = self.http.request( - location, 'POST', body=xml, headers=headers) - self.response = response - self.content = content - - log.debug('\n'.join(["%s: %s" % (k, v) for k, v in response.items()])) - log.debug(content) - return content - - def get_operation(self, method): - # try to find operation in wsdl file - soap_ver = self.__soap_ns.startswith('soap12') and 'soap12' or 'soap11' - if not self.service_port: - for service_name, service in self.services.items(): - for port_name, port in [port for port in service['ports'].items()]: - if port['soap_ver'] == soap_ver: - self.service_port = service_name, port_name - break - else: - raise RuntimeError('Cannot determine service in WSDL: ' - 'SOAP version: %s' % soap_ver) - else: - port = self.services[self.service_port[0]]['ports'][self.service_port[1]] - if not self.location: - self.location = port['location'] - operation = port['operations'].get(method) - if not operation: - raise RuntimeError('Operation %s not found in WSDL: ' - 'Service/Port Type: %s' % - (method, self.service_port)) - return operation - - def wsdl_call(self, method, *args, **kwargs): - """Pre and post process SOAP call, input and output parameters using WSDL""" - soap_uri = soap_namespaces[self.__soap_ns] - operation = self.get_operation(method) - - # get i/o type declarations: - input = operation['input'] - output = operation['output'] - header = operation.get('header') - if 'action' in operation: - self.action = operation['action'] - - if 'namespace' in operation: - self.namespace = operation['namespace'] - - # construct header and parameters - if header: - self.__call_headers = sort_dict(header, self.__headers) - method, params = self.wsdl_call_get_params(method, input, *args, **kwargs) - - # call remote procedure - response = self.call(method, *params) - # parse results: - resp = response('Body', ns=soap_uri).children().unmarshall(output) - return resp and list(resp.values())[0] # pass Response tag children - - def wsdl_call_get_params(self, method, input, *args, **kwargs): - """Build params from input and args/kwargs""" - params = inputname = inputargs = None - all_args = {} - if input: - inputname = list(input.keys())[0] - inputargs = input[inputname] - - if input and args: - # convert positional parameters to named parameters: - d = {} - for idx, arg in enumerate(args): - key = list(inputargs.keys())[idx] - if isinstance(arg, dict): - if key in arg: - d[key] = arg[key] - else: - raise KeyError('Unhandled key %s. use client.help(method)') - else: - d[key] = arg - all_args.update({inputname: d}) - - if input and (kwargs or all_args): - if kwargs: - all_args.update({inputname: kwargs}) - valid, errors, warnings = self.wsdl_validate_params(input, all_args) - if not valid: - raise ValueError('Invalid Args Structure. Errors: %s' % errors) - params = list(sort_dict(input, all_args).values())[0].items() - if self.__soap_server == 'axis': - # use the operation name - method = method - else: - # use the message (element) name - method = inputname - #elif not input: - #TODO: no message! (see wsmtxca.dummy) - else: - params = kwargs and kwargs.items() - - return (method, params) - - def wsdl_validate_params(self, struct, value): - """Validate the arguments (actual values) for the parameters structure. - Fail for any invalid arguments or type mismatches.""" - errors = [] - warnings = [] - valid = True - - # Determine parameter type - if type(struct) == type(value): - typematch = True - if not isinstance(struct, dict) and isinstance(value, dict): - typematch = True # struct can be an OrderedDict - else: - typematch = False - - if struct == str: - struct = unicode # fix for py2 vs py3 string handling - - if not isinstance(struct, (list, dict, tuple)) and struct in TYPE_MAP.keys(): - try: - struct(value) # attempt to cast input to parameter type - except: - valid = False - errors.append('Type mismatch for argument value. parameter(%s): %s, value(%s): %s' % (type(struct), struct, type(value), value)) - - elif isinstance(struct, list) and len(struct) == 1 and not isinstance(value, list): - # parameter can have a dict in a list: [{}] indicating a list is allowed, but not needed if only one argument. - next_valid, next_errors, next_warnings = self.wsdl_validate_params(struct[0], value) - if not next_valid: - valid = False - errors.extend(next_errors) - warnings.extend(next_warnings) - - # traverse tree - elif isinstance(struct, dict): - if struct and value: - for key in value: - if key not in struct: - valid = False - errors.append('Argument key %s not in parameter. parameter: %s, args: %s' % (key, struct, value)) - else: - next_valid, next_errors, next_warnings = self.wsdl_validate_params(struct[key], value[key]) - if not next_valid: - valid = False - errors.extend(next_errors) - warnings.extend(next_warnings) - for key in struct: - if key not in value: - warnings.append('Parameter key %s not in args. parameter: %s, value: %s' % (key, struct, value)) - elif struct and not value: - warnings.append('parameter keys not in args. parameter: %s, args: %s' % (struct, value)) - elif not struct and value: - valid = False - errors.append('Args keys not in parameter. parameter: %s, args: %s' % (struct, value)) - else: - pass - elif isinstance(struct, list): - struct_list_value = struct[0] - for item in value: - next_valid, next_errors, next_warnings = self.wsdl_validate_params(struct_list_value, item) - if not next_valid: - valid = False - errors.extend(next_errors) - warnings.extend(next_warnings) - elif not typematch: - valid = False - errors.append('Type mismatch. parameter(%s): %s, value(%s): %s' % (type(struct), struct, type(value), value)) - - return (valid, errors, warnings) - - def help(self, method): - """Return operation documentation and invocation/returned value example""" - operation = self.get_operation(method) - input = operation.get('input') - input = input and input.values() and list(input.values())[0] - if isinstance(input, dict): - input = ", ".join("%s=%s" % (k, repr(v)) for k, v in input.items()) - elif isinstance(input, list): - input = repr(input) - output = operation.get('output') - if output: - output = list(operation['output'].values())[0] - headers = operation.get('headers') or None - return "%s(%s)\n -> %s:\n\n%s\nHeaders: %s" % ( - method, - input or '', - output and output or '', - operation.get('documentation', ''), - headers, - ) - - def wsdl_parse(self, url, cache=False): - """Parse Web Service Description v1.1""" - - log.debug('Parsing wsdl url: %s' % url) - # Try to load a previously parsed wsdl: - force_download = False - if cache: - # make md5 hash of the url for caching... - filename_pkl = '%s.pkl' % hashlib.md5(url.encode('utf-8')).hexdigest() - if isinstance(cache, str): - filename_pkl = os.path.join(cache, filename_pkl) - if os.path.exists(filename_pkl): - log.debug('Unpickle file %s' % (filename_pkl, )) - f = open(filename_pkl, 'rb') - pkl = pickle.load(f) - f.close() - # sanity check: - if pkl['version'][:-1] != __version__.split(' ')[0][:-1] or pkl['url'] != url: - import warnings - warnings.warn('version or url mismatch! discarding cached wsdl', RuntimeWarning) - log.debug('Version: %s %s' % (pkl['version'], __version__)) - log.debug('URL: %s %s' % (pkl['url'], url)) - force_download = True - else: - self.namespace = pkl['namespace'] - self.documentation = pkl['documentation'] - return pkl['services'] - - soap_ns = { - 'http://schemas.xmlsoap.org/wsdl/soap/': 'soap11', - 'http://schemas.xmlsoap.org/wsdl/soap12/': 'soap12', - } - wsdl_uri = 'http://schemas.xmlsoap.org/wsdl/' - xsd_uri = 'http://www.w3.org/2001/XMLSchema' - xsi_uri = 'http://www.w3.org/2001/XMLSchema-instance' - - # always return an unicode object: - REVERSE_TYPE_MAP['string'] = str - - # Open uri and read xml: - xml = fetch(url, self.http, cache, force_download, self.wsdl_basedir) - # Parse WSDL XML: - wsdl = SimpleXMLElement(xml, namespace=wsdl_uri) - - # Extract useful data: - self.namespace = None - self.documentation = unicode(wsdl('documentation', error=False)) or '' - - # some wsdl are splitted down in several files, join them: - imported_wsdls = {} - for element in wsdl.children() or []: - if element.get_local_name() in ('import'): - wsdl_namespace = element['namespace'] - wsdl_location = element['location'] - if wsdl_location is None: - log.warning('WSDL location not provided for %s!' % wsdl_namespace) - continue - if wsdl_location in imported_wsdls: - log.warning('WSDL %s already imported!' % wsdl_location) - continue - imported_wsdls[wsdl_location] = wsdl_namespace - log.debug('Importing wsdl %s from %s' % (wsdl_namespace, wsdl_location)) - # Open uri and read xml: - xml = fetch(wsdl_location, self.http, cache, force_download, self.wsdl_basedir) - # Parse imported XML schema (recursively): - imported_wsdl = SimpleXMLElement(xml, namespace=xsd_uri) - # merge the imported wsdl into the main document: - wsdl.import_node(imported_wsdl) - # warning: do not process schemas to avoid infinite recursion! - - - # detect soap prefix and uri (xmlns attributes of ) - xsd_ns = None - soap_uris = {} - for k, v in wsdl[:]: - if v in soap_ns and k.startswith('xmlns:'): - soap_uris[get_local_name(k)] = v - if v == xsd_uri and k.startswith('xmlns:'): - xsd_ns = get_local_name(k) - - services = {} - bindings = {} # binding_name: binding - operations = {} # operation_name: operation - port_type_bindings = {} # port_type_name: binding - messages = {} # message: element - elements = {} # element: type def - - for service in wsdl.service: - service_name = service['name'] - if not service_name: - continue # empty service? - serv = services.setdefault(service_name, {'ports': {}}) - serv['documentation'] = service['documentation'] or '' - for port in service.port: - binding_name = get_local_name(port['binding']) - operations[binding_name] = {} - address = port('address', ns=list(soap_uris.values()), error=False) - location = address and address['location'] or None - soap_uri = address and soap_uris.get(address.get_prefix()) - soap_ver = soap_uri and soap_ns.get(soap_uri) - bindings[binding_name] = {'name': binding_name, - 'service_name': service_name, - 'location': location, - 'soap_uri': soap_uri, - 'soap_ver': soap_ver, } - serv['ports'][port['name']] = bindings[binding_name] - - for binding in wsdl.binding: - binding_name = binding['name'] - soap_binding = binding('binding', ns=list(soap_uris.values()), error=False) - transport = soap_binding and soap_binding['transport'] or None - port_type_name = get_local_name(binding['type']) - bindings[binding_name].update({ - 'port_type_name': port_type_name, - 'transport': transport, 'operations': {}, - }) - if port_type_name not in port_type_bindings: - port_type_bindings[port_type_name] = [] - port_type_bindings[port_type_name].append(bindings[binding_name]) - for operation in binding.operation: - op_name = operation['name'] - op = operation('operation', ns=list(soap_uris.values()), error=False) - action = op and op['soapAction'] - d = operations[binding_name].setdefault(op_name, {}) - bindings[binding_name]['operations'][op_name] = d - d.update({'name': op_name}) - d['parts'] = {} - # input and/or ouput can be not present! - input = operation('input', error=False) - body = input and input('body', ns=list(soap_uris.values()), error=False) - d['parts']['input_body'] = body and body['parts'] or None - output = operation('output', error=False) - body = output and output('body', ns=list(soap_uris.values()), error=False) - d['parts']['output_body'] = body and body['parts'] or None - header = input and input('header', ns=list(soap_uris.values()), error=False) - d['parts']['input_header'] = header and {'message': header['message'], 'part': header['part']} or None - header = output and output('header', ns=list(soap_uris.values()), error=False) - d['parts']['output_header'] = header and {'message': header['message'], 'part': header['part']} or None - if action: - d['action'] = action - - # check axis2 namespace at schema types attributes (europa.eu checkVat) - if "http://xml.apache.org/xml-soap" in dict(wsdl[:]).values(): - # get the sub-namespace in the first schema element (see issue 8) - schema = wsdl.types('schema', ns=xsd_uri) - attrs = dict(schema[:]) - self.namespace = attrs.get('targetNamespace', self.namespace) - - imported_schemas = {} - global_namespaces = {} - - # process current wsdl schema: - for schema in wsdl.types('schema', ns=xsd_uri): - preprocess_schema(schema, imported_schemas, elements, xsd_uri, self.__soap_server, self.http, cache, force_download, self.wsdl_basedir, global_namespaces=global_namespaces) - - postprocess_element(elements) - - for message in wsdl.message: - for part in message('part', error=False) or []: - element = {} - element_name = part['element'] - if not element_name: - # some implementations (axis) uses type instead - element_name = part['type'] - type_ns = get_namespace_prefix(element_name) - type_uri = wsdl.get_namespace_uri(type_ns) - if type_uri == xsd_uri: - element_name = get_local_name(element_name) - fn = REVERSE_TYPE_MAP.get(element_name, None) - element = {part['name']: fn} - # emulate a true Element (complexType) - list(messages.setdefault((message['name'], None), {message['name']: OrderedDict()}).values())[0].update(element) - else: - element_name = get_local_name(element_name) - fn = elements.get(make_key(element_name, 'element', type_uri)) - if not fn: - # some axis servers uses complexType for part messages - fn = elements.get(make_key(element_name, 'complexType', type_uri)) - element = {message['name']: {part['name']: fn}} - else: - element = {element_name: fn} - messages[(message['name'], part['name'])] = element - - for port_type in wsdl.portType: - port_type_name = port_type['name'] - - for binding in port_type_bindings.get(port_type_name, []): - for operation in port_type.operation: - op_name = operation['name'] - op = operations[binding['name']][op_name] - op['documentation'] = unicode(operation('documentation', error=False)) or '' - if binding['soap_ver']: - #TODO: separe operation_binding from operation (non SOAP?) - if operation('input', error=False): - input_msg = get_local_name(operation.input['message']) - input_header = op['parts'].get('input_header') - if input_header: - header_msg = get_local_name(input_header.get('message')) - header_part = get_local_name(input_header.get('part')) - # warning: some implementations use a separate message! - header = get_message(messages, header_msg or input_msg, header_part) - else: - header = None # not enought info to search the header message: - op['input'] = get_message(messages, input_msg, op['parts'].get('input_body')) - op['header'] = header - try: - ns_uri = list(op['input'].values())[0].namespace - except AttributeError: - # TODO: fix if no parameters parsed or "variants" - ns = get_namespace_prefix(operation.input['message']) - ns_uri = operation.get_namespace_uri(ns) - if ns_uri: - op['namespace'] = ns_uri - else: - op['input'] = None - op['header'] = None - if operation('output', error=False): - output_msg = get_local_name(operation.output['message']) - op['output'] = get_message(messages, output_msg, op['parts'].get('output_body')) - else: - op['output'] = None - - # dump the full service/port/operation map - #log.debug(pprint.pformat(services)) - - # Save parsed wsdl (cache) - if cache: - f = open(filename_pkl, "wb") - pkl = { - 'version': __version__.split(' ')[0], - 'url': url, - 'namespace': self.namespace, - 'documentation': self.documentation, - 'services': services, - } - pickle.dump(pkl, f) - f.close() - - return services - - def __setitem__(self, item, value): - """Set SOAP Header value - this header will be sent for every request.""" - self.__headers[item] = value - - def close(self): - """Finish the connection and remove temp files""" - self.http.close() - if self.cacert.startswith(tempfile.gettempdir()): - log.debug('removing %s' % self.cacert) - os.unlink(self.cacert) - - -def parse_proxy(proxy_str): - """Parses proxy address user:pass@host:port into a dict suitable for httplib2""" - proxy_dict = {} - if proxy_str is None: - return - if '@' in proxy_str: - user_pass, host_port = proxy_str.split('@') - else: - user_pass, host_port = '', proxy_str - if ':' in host_port: - host, port = host_port.split(':') - proxy_dict['proxy_host'], proxy_dict['proxy_port'] = host, int(port) - if ':' in user_pass: - proxy_dict['proxy_user'], proxy_dict['proxy_pass'] = user_pass.split(':') - return proxy_dict - - -if __name__ == '__main__': - pass diff --git a/pysimplesoap/helpers.py b/pysimplesoap/helpers.py deleted file mode 100644 index 434a886..0000000 --- a/pysimplesoap/helpers.py +++ /dev/null @@ -1,465 +0,0 @@ -#!/usr/bin/python -# -*- coding: utf-8 -*- -# This program is free software; you can redistribute it and/or modify -# it under the terms of the GNU Lesser General Public License as published by the -# Free Software Foundation; either version 3, or (at your option) any later -# version. -# -# This program is distributed in the hope that it will be useful, but -# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTIBILITY -# or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License -# for more details. - -"""Pythonic simple SOAP Client helpers""" - - -from __future__ import unicode_literals -import sys -if sys.version > '3': - basestring = unicode = str - -import datetime -from decimal import Decimal -import os -import logging -import hashlib -import warnings - -try: - import urllib2 - from urlparse import urlsplit -except ImportError: - from urllib import request as urllib2 - from urllib.parse import urlsplit - -from . import __author__, __copyright__, __license__, __version__ - - -log = logging.getLogger(__name__) - - -def fetch(url, http, cache=False, force_download=False, wsdl_basedir=''): - """Download a document from a URL, save it locally if cache enabled""" - - # check / append a valid schema if not given: - url_scheme, netloc, path, query, fragment = urlsplit(url) - if not url_scheme in ('http', 'https', 'file'): - for scheme in ('http', 'https', 'file'): - try: - if not url.startswith("/") and scheme in ('http', 'https'): - tmp_url = "%s://%s" % (scheme, os.path.join(wsdl_basedir, url)) - else: - tmp_url = "%s:%s" % (scheme, os.path.join(wsdl_basedir, url)) - log.debug('Scheme not found, trying %s' % scheme) - return fetch(tmp_url, http, cache, force_download, wsdl_basedir) - except Exception as e: - log.error(e) - raise RuntimeError('No scheme given for url: %s' % url) - - # make md5 hash of the url for caching... - filename = '%s.xml' % hashlib.md5(url.encode('utf8')).hexdigest() - if isinstance(cache, basestring): - filename = os.path.join(cache, filename) - if cache and os.path.exists(filename) and not force_download: - log.info('Reading file %s' % filename) - f = open(filename, 'r') - xml = f.read() - f.close() - else: - if url_scheme == 'file': - log.info('Fetching url %s using urllib2' % url) - f = urllib2.urlopen(url) - xml = f.read() - else: - log.info('GET %s using %s' % (url, http._wrapper_version)) - response, xml = http.request(url, 'GET', None, {}) - if cache: - log.info('Writing file %s' % filename) - if not os.path.isdir(cache): - os.makedirs(cache) - f = open(filename, 'w') - if isinstance(xml, bytes): - xml = xml.decode('utf-8') - f.write(xml) - f.close() - return xml - - -def sort_dict(od, d): - """Sort parameters (same order as xsd:sequence)""" - if isinstance(od, dict): - ret = OrderedDict() - for k in od.keys(): - v = d.get(k) - # don't append null tags! - if v is not None: - if isinstance(v, dict): - v = sort_dict(od[k], v) - elif isinstance(v, list): - v = [sort_dict(od[k][0], v1) for v1 in v] - ret[k] = v - if hasattr(od, 'namespace'): - ret.namespace = od.namespace - return ret - else: - return d - - -def make_key(element_name, element_type, namespace): - """Return a suitable key for elements""" - # only distinguish 'element' vs other types - if element_type in ('complexType', 'simpleType'): - eltype = 'complexType' - else: - eltype = element_type - if eltype not in ('element', 'complexType', 'simpleType'): - raise RuntimeError("Unknown element type %s = %s" % (element_name, eltype)) - return (element_name, eltype, namespace) - - -def process_element(elements, element_name, node, element_type, xsd_uri, dialect, namespace, - soapenc_uri = 'http://schemas.xmlsoap.org/soap/encoding/'): - """Parse and define simple element types""" - - - - log.debug('Processing element %s %s' % (element_name, element_type)) - for tag in node: - if tag.get_local_name() in ('annotation', 'documentation'): - continue - elif tag.get_local_name() in ('element', 'restriction'): - log.debug('%s has no children! %s' % (element_name, tag)) - children = tag # element "alias"? - alias = True - elif tag.children(): - children = tag.children() - alias = False - else: - log.debug('%s has no children! %s' % (element_name, tag)) - continue # TODO: abstract? - d = OrderedDict() - d.namespace = namespace - for e in children: - t = e['type'] - if not t: - t = e['base'] # complexContent (extension)! - if not t: - t = 'anyType' # no type given! - t = t.split(":") - if len(t) > 1: - ns, type_name = t - else: - ns, type_name = None, t[0] - if element_name == type_name: - pass # warning with infinite recursion - uri = ns and e.get_namespace_uri(ns) or xsd_uri - if uri in (xsd_uri, soapenc_uri) and type_name != 'Array': - # look for the type, None == any - fn = REVERSE_TYPE_MAP.get(type_name, None) - elif uri == soapenc_uri and type_name == 'Array': - # arrays of simple types (look at the attribute tags): - fn = [] - for a in e.children(): - for k, v in a[:]: - if k.endswith(":arrayType"): - type_name = v - if ":" in type_name: - type_name = type_name[type_name.index(":")+1:] - if "[]" in type_name: - type_name = type_name[:type_name.index("[]")] - fn.append(REVERSE_TYPE_MAP.get(type_name, None)) - else: - fn = None - if not fn: - # simple / complex type, postprocess later - if ns: - fn_namespace = uri # use the specified namespace - else: - fn_namespace = namespace # use parent namespace (default) - for k, v in e[:]: - if k.startswith("xmlns:"): - # get the namespace uri from the element - fn_namespace = v - fn = elements.setdefault(make_key(type_name, 'complexType', fn_namespace), OrderedDict()) - - if e['maxOccurs'] == 'unbounded' or (uri == soapenc_uri and type_name == 'Array'): - # it's an array... TODO: compound arrays? and check ns uri! - if isinstance(fn, OrderedDict): - if len(children) > 1 and dialect in ('jetty',): - # Jetty style support - # {'ClassName': [{'attr1': val1, 'attr2': val2}] - fn.array = True - else: - # .NET style support (backward compatibility) - # [{'ClassName': {'attr1': val1, 'attr2': val2}] - d.array = True - else: - if dialect in ('jetty',): - # scalar support [{'attr1': [val1]}] - fn = [fn] - else: - d.array = True - - if e['name'] is not None and not alias: - e_name = e['name'] - d[e_name] = fn - else: - log.debug('complexContent/simpleType/element %s = %s' % (element_name, type_name)) - d[None] = fn - if e is not None and e.get_local_name() == 'extension' and e.children(): - # extend base element: - process_element(elements, element_name, e.children(), element_type, xsd_uri, dialect, namespace) - elements.setdefault(make_key(element_name, element_type, namespace), OrderedDict()).update(d) - - -def postprocess_element(elements): - """Fix unresolved references (elements referenced before its definition, thanks .net)""" - for k, v in elements.items(): - if isinstance(v, OrderedDict): - if v != elements: # TODO: fix recursive elements - postprocess_element(v) - if None in v and v[None]: # extension base? - if isinstance(v[None], dict): - for i, kk in enumerate(v[None]): - # extend base -keep orginal order- - if v[None] is not None: - elements[k].insert(kk, v[None][kk], i) - del v[None] - else: # "alias", just replace - log.debug('Replacing %s = %s' % (k, v[None])) - elements[k] = v[None] - #break - if v.array: - elements[k] = [v] # convert arrays to python lists - if isinstance(v, list): - for n in v: # recurse list - if isinstance(n, (OrderedDict, list)): - postprocess_element(n) - - -def get_message(messages, message_name, part_name): - if part_name: - # get the specific part of the message: - return messages.get((message_name, part_name)) - else: - # get the first part for the specified message: - for (message_name_key, part_name_key), message in messages.items(): - if message_name_key == message_name: - return message - - -get_local_name = lambda s: s and str((':' in s) and s.split(':')[1] or s) -get_namespace_prefix = lambda s: s and str((':' in s) and s.split(':')[0] or None) - - -def preprocess_schema(schema, imported_schemas, elements, xsd_uri, dialect, http, cache, force_download, wsdl_basedir, global_namespaces=None, qualified=False): - """Find schema elements and complex types""" - - from .simplexml import SimpleXMLElement # here to avoid recursive imports - - # analyze the namespaces used in this schema - local_namespaces = {} - for k, v in schema[:]: - if k.startswith("xmlns"): - local_namespaces[get_local_name(k)] = v - if k == 'targetNamespace': - # URI namespace reference for this schema - local_namespaces[None] = v - if k == 'elementFormDefault' and v == "qualified" and qualified is None: - qualified = True - # add schema namespaces to the global namespace dict = {URI: ns prefix} - for ns in local_namespaces.values(): - if ns not in global_namespaces: - global_namespaces[ns] = 'ns%s' % len(global_namespaces) - - for element in schema.children() or []: - if element.get_local_name() in ('import', 'include',): - schema_namespace = element['namespace'] - schema_location = element['schemaLocation'] - if schema_location is None: - log.debug('Schema location not provided for %s!' % schema_namespace) - continue - if schema_location in imported_schemas: - log.debug('Schema %s already imported!' % schema_location) - continue - imported_schemas[schema_location] = schema_namespace - log.debug('Importing schema %s from %s' % (schema_namespace, schema_location)) - # Open uri and read xml: - xml = fetch(schema_location, http, cache, force_download, wsdl_basedir) - - # Parse imported XML schema (recursively): - imported_schema = SimpleXMLElement(xml, namespace=xsd_uri) - preprocess_schema(imported_schema, imported_schemas, elements, xsd_uri, dialect, http, cache, force_download, wsdl_basedir, global_namespaces) - - element_type = element.get_local_name() - if element_type in ('element', 'complexType', "simpleType"): - namespace = local_namespaces[None] # get targetNamespace - element_ns = global_namespaces[ns] # get the prefix - element_name = element['name'] - log.debug("Parsing Element %s: %s" % (element_type, element_name)) - if element.get_local_name() == 'complexType': - children = element.children() - elif element.get_local_name() == 'simpleType': - children = element('restriction', ns=xsd_uri) - elif element.get_local_name() == 'element' and element['type']: - children = element - else: - children = element.children() - if children: - children = children.children() - elif element.get_local_name() == 'element': - children = element - if children: - process_element(elements, element_name, children, element_type, xsd_uri, dialect, namespace) - - -# simplexml utilities: - -try: - _strptime = datetime.datetime.strptime -except AttributeError: # python2.4 - _strptime = lambda s, fmt: datetime.datetime(*(time.strptime(s, fmt)[:6])) - - -# Functions to serialize/deserialize special immutable types: -def datetime_u(s): - fmt = "%Y-%m-%dT%H:%M:%S" - try: - return _strptime(s, fmt) - except ValueError: - try: - # strip utc offset - if s[-3] == ":" and s[-6] in (' ', '-', '+'): - warnings.warn('removing unsupported UTC offset', RuntimeWarning) - s = s[:-6] - # parse microseconds - try: - return _strptime(s, fmt + ".%f") - except: - return _strptime(s, fmt) - except ValueError: - # strip microseconds (not supported in this platform) - if "." in s: - warnings.warn('removing unsuppported microseconds', RuntimeWarning) - s = s[:s.index(".")] - return _strptime(s, fmt) - -datetime_m = lambda dt: dt.isoformat('T') -date_u = lambda s: _strptime(s[0:10], "%Y-%m-%d").date() -date_m = lambda d: d.strftime("%Y-%m-%d") -time_u = lambda s: _strptime(s, "%H:%M:%S").time() -time_m = lambda d: d.strftime("%H%M%S") -bool_u = lambda s: {'0': False, 'false': False, '1': True, 'true': True}[s] -bool_m = lambda s: {False: 'false', True: 'true'}[s] - - -# aliases: -class Alias(object): - def __init__(self, py_type, xml_type): - self.py_type, self.xml_type = py_type, xml_type - - def __call__(self, value): - return self.py_type(value) - - def __repr__(self): - return "" % (self.xml_type, self.py_type) - -if sys.version > '3': - long = Alias(int, 'long') -byte = Alias(str, 'byte') -short = Alias(int, 'short') -double = Alias(float, 'double') -integer = Alias(long, 'integer') -DateTime = datetime.datetime -Date = datetime.date -Time = datetime.time - -# Define convertion function (python type): xml schema type -TYPE_MAP = { - #str: 'string', - unicode: 'string', - bool: 'boolean', - short: 'short', - byte: 'byte', - int: 'int', - long: 'long', - integer: 'integer', - float: 'float', - double: 'double', - Decimal: 'decimal', - datetime.datetime: 'dateTime', - datetime.date: 'date', -} -TYPE_MARSHAL_FN = { - datetime.datetime: datetime_m, - datetime.date: date_m, - bool: bool_m -} -TYPE_UNMARSHAL_FN = { - datetime.datetime: datetime_u, - datetime.date: date_u, - bool: bool_u, - str: unicode, -} - -REVERSE_TYPE_MAP = dict([(v, k) for k, v in TYPE_MAP.items()]) - -REVERSE_TYPE_MAP.update({ - 'base64Binary': str, -}) - -class OrderedDict(dict): - """Minimal ordered dictionary for xsd:sequences""" - def __init__(self): - self.__keys = [] - self.array = False - self.namespace = None - - def __setitem__(self, key, value): - if key not in self.__keys: - self.__keys.append(key) - dict.__setitem__(self, key, value) - - def insert(self, key, value, index=0): - if key not in self.__keys: - self.__keys.insert(index, key) - dict.__setitem__(self, key, value) - - def __delitem__(self, key): - if key in self.__keys: - self.__keys.remove(key) - dict.__delitem__(self, key) - - def __iter__(self): - return iter(self.__keys) - - def keys(self): - return self.__keys - - def items(self): - return [(key, self[key]) for key in self.__keys] - - def update(self, other): - for k, v in other.items(): - self[k] = v - # do not change if we are an array but the other is not: - if isinstance(other, OrderedDict) and not self.array: - self.array = other.array - if isinstance(other, OrderedDict) and not self.namespace: - self.namespace = other.namespace - - def copy(self): - "Make a duplicate" - new = OrderedDict() - new.update(self) - return new - - def __str__(self): - return "%s" % dict.__str__(self) - - def __repr__(self): - s = "{%s}" % ", ".join(['%s: %s' % (repr(k), repr(v)) for k, v in self.items()]) - if self.array and False: - s = "[%s]" % s - return s - diff --git a/pysimplesoap/server.py b/pysimplesoap/server.py deleted file mode 100644 index 82fb33d..0000000 --- a/pysimplesoap/server.py +++ /dev/null @@ -1,560 +0,0 @@ -#!/usr/bin/python -# -*- coding: utf-8 -*- -# This program is free software; you can redistribute it and/or modify -# it under the terms of the GNU Lesser General Public License as published by the -# Free Software Foundation; either version 3, or (at your option) any later -# version. -# -# This program is distributed in the hope that it will be useful, but -# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTIBILITY -# or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License -# for more details. - -"""Pythonic simple SOAP Server implementation""" - - -from __future__ import unicode_literals - -import sys -import logging -import re -import traceback -try: - from BaseHTTPServer import BaseHTTPRequestHandler, HTTPServer -except ImportError: - from http.server import BaseHTTPRequestHandler, HTTPServer - -from . import __author__, __copyright__, __license__, __version__ -from .simplexml import SimpleXMLElement, TYPE_MAP, Date, Decimal - -log = logging.getLogger(__name__) - -# Deprecated? -NS_RX = re.compile(r'xmlns:(\w+)="(.+?)"') - - -class SoapDispatcher(object): - """Simple Dispatcher for SOAP Server""" - - def __init__(self, name, documentation='', action='', location='', - namespace=None, prefix=False, - soap_uri="http://schemas.xmlsoap.org/soap/envelope/", - soap_ns='soap', - namespaces={}, - pretty=False, - debug=False, - **kwargs): - """ - :param namespace: Target namespace; xmlns=targetNamespace - :param prefix: Prefix for target namespace; xmlns:prefix=targetNamespace - :param namespaces: Specify additional namespaces; example: {'external': 'http://external.mt.moboperator'} - :param pretty: Prettifies generated xmls - :param debug: Use to add tracebacks in generated xmls. - - Multiple namespaces - =================== - - It is possible to support multiple namespaces. - You need to specify additional namespaces by passing `namespace` parameter. - - >>> dispatcher = SoapDispatcher( - ... name = "MTClientWS", - ... location = "http://localhost:8008/ws/MTClientWS", - ... action = 'http://localhost:8008/ws/MTClientWS', # SOAPAction - ... namespace = "http://external.mt.moboperator", prefix="external", - ... documentation = 'moboperator MTClientWS', - ... namespaces = { - ... 'external': 'http://external.mt.moboperator', - ... 'model': 'http://model.common.mt.moboperator' - ... }, - ... ns = True) - - Now the registered method must return node names with namespaces' prefixes. - - >>> def _multi_ns_func(self, serviceMsisdn): - ... ret = { - ... 'external:activateSubscriptionsReturn': [ - ... {'model:code': '0'}, - ... {'model:description': 'desc'}, - ... ]} - ... return ret - - Our prefixes will be changed to those used by the client. - """ - self.methods = {} - self.name = name - self.documentation = documentation - self.action = action # base SoapAction - self.location = location - self.namespace = namespace # targetNamespace - self.prefix = prefix - self.soap_ns = soap_ns - self.soap_uri = soap_uri - self.namespaces = namespaces - self.pretty = pretty - self.debug = debug - - @staticmethod - def _extra_namespaces(xml, ns): - """Extends xml with extra namespaces. - :param ns: dict with namespaceUrl:prefix pairs - :param xml: XML node to modify - """ - if ns: - _tpl = 'xmlns:%s="%s"' - _ns_str = " ".join([_tpl % (prefix, uri) for uri, prefix in ns.items() if uri not in xml]) - xml = xml.replace('/>', ' ' + _ns_str + '/>') - return xml - - def register_function(self, name, fn, returns=None, args=None, doc=None): - self.methods[name] = fn, returns, args, doc or getattr(fn, "__doc__", "") - - def dispatch(self, xml, action=None): - """Receive and process SOAP call""" - # default values: - prefix = self.prefix - ret = fault = None - soap_ns, soap_uri = self.soap_ns, self.soap_uri - soap_fault_code = 'VersionMismatch' - name = None - - # namespaces = [('model', 'http://model.common.mt.moboperator'), ('external', 'http://external.mt.moboperator')] - _ns_reversed = dict(((v, k) for k, v in self.namespaces.items())) # Switch keys-values - # _ns_reversed = {'http://external.mt.moboperator': 'external', 'http://model.common.mt.moboperator': 'model'} - - try: - request = SimpleXMLElement(xml, namespace=self.namespace) - - # detect soap prefix and uri (xmlns attributes of Envelope) - for k, v in request[:]: - if v in ("http://schemas.xmlsoap.org/soap/envelope/", - "http://www.w3.org/2003/05/soap-env",): - soap_ns = request.attributes()[k].localName - soap_uri = request.attributes()[k].value - - # If the value from attributes on Envelope is in additional namespaces - elif v in self.namespaces.values(): - _ns = request.attributes()[k].localName - _uri = request.attributes()[k].value - _ns_reversed[_uri] = _ns # update with received alias - # Now we change 'external' and 'model' to the received forms i.e. 'ext' and 'mod' - # After that we know how the client has prefixed additional namespaces - - ns = NS_RX.findall(xml) - for k, v in ns: - if v in self.namespaces.values(): - _ns_reversed[v] = k - - soap_fault_code = 'Client' - - # parse request message and get local method - method = request('Body', ns=soap_uri).children()(0) - if action: - # method name = action - name = action[len(self.action)+1:-1] - prefix = self.prefix - if not action or not name: - # method name = input message name - name = method.get_local_name() - prefix = method.get_prefix() - - log.debug('dispatch method: %s', name) - function, returns_types, args_types, doc = self.methods[name] - log.debug('returns_types %s', returns_types) - - # de-serialize parameters (if type definitions given) - if args_types: - args = method.children().unmarshall(args_types) - elif args_types is None: - args = {'request': method} # send raw request - else: - args = {} # no parameters - - soap_fault_code = 'Server' - # execute function - ret = function(**args) - log.debug('dispathed method returns: %s', ret) - - except Exception: # This shouldn't be one huge try/except - import sys - etype, evalue, etb = sys.exc_info() - log.error(traceback.format_exc()) - if self.debug: - detail = ''.join(traceback.format_exception(etype, evalue, etb)) - detail += '\n\nXML REQUEST\n\n' + xml - else: - detail = None - fault = {'faultcode': "%s.%s" % (soap_fault_code, etype.__name__), - 'faultstring': evalue, - 'detail': detail} - - # build response message - if not prefix: - xml = """<%(soap_ns)s:Envelope xmlns:%(soap_ns)s="%(soap_uri)s"/>""" - else: - xml = """<%(soap_ns)s:Envelope xmlns:%(soap_ns)s="%(soap_uri)s" - xmlns:%(prefix)s="%(namespace)s"/>""" - - xml %= { # a %= {} is a shortcut for a = a % {} - 'namespace': self.namespace, - 'prefix': prefix, - 'soap_ns': soap_ns, - 'soap_uri': soap_uri - } - - # Now we add extra namespaces - xml = SoapDispatcher._extra_namespaces(xml, _ns_reversed) - - # Change our namespace alias to that given by the client. - # We put [('model', 'http://model.common.mt.moboperator'), ('external', 'http://external.mt.moboperator')] - # mix it with {'http://external.mt.moboperator': 'ext', 'http://model.common.mt.moboperator': 'mod'} - mapping = dict(((k, _ns_reversed[v]) for k, v in self.namespaces.items())) # Switch keys-values and change value - # and get {'model': u'mod', 'external': u'ext'} - - response = SimpleXMLElement(xml, - namespace=self.namespace, - namespaces_map=mapping, - prefix=prefix) - - response['xmlns:xsi'] = "http://www.w3.org/2001/XMLSchema-instance" - response['xmlns:xsd'] = "http://www.w3.org/2001/XMLSchema" - - body = response.add_child("%s:Body" % soap_ns, ns=False) - - if fault: - # generate a Soap Fault (with the python exception) - body.marshall("%s:Fault" % soap_ns, fault, ns=False) - else: - # return normal value - res = body.add_child("%sResponse" % name, ns=prefix) - if not prefix: - res['xmlns'] = self.namespace # add target namespace - - # serialize returned values (response) if type definition available - if returns_types: - if not isinstance(ret, dict): - res.marshall(returns_types.keys()[0], ret, ) - else: - for k, v in ret.items(): - res.marshall(k, v) - elif returns_types is None: - # merge xmlelement returned - res.import_node(ret) - elif returns_types == {}: - log.warning('Given returns_types is an empty dict.') - - return response.as_xml(pretty=self.pretty) - - # Introspection functions: - - def list_methods(self): - """Return a list of aregistered operations""" - return [(method, doc) for method, (function, returns, args, doc) in self.methods.items()] - - def help(self, method=None): - """Generate sample request and response messages""" - (function, returns, args, doc) = self.methods[method] - xml = """ - -<%(method)s xmlns="%(namespace)s"/> -""" % {'method': method, 'namespace': self.namespace} - request = SimpleXMLElement(xml, namespace=self.namespace, prefix=self.prefix) - if args: - items = args.items() - elif args is None: - items = [('value', None)] - else: - items = [] - for k, v in items: - request(method).marshall(k, v, add_comments=True, ns=False) - - xml = """ - -<%(method)sResponse xmlns="%(namespace)s"/> -""" % {'method': method, 'namespace': self.namespace} - response = SimpleXMLElement(xml, namespace=self.namespace, prefix=self.prefix) - if returns: - items = returns.items() - elif args is None: - items = [('value', None)] - else: - items = [] - for k, v in items: - response('%sResponse' % method).marshall(k, v, add_comments=True, ns=False) - - return request.as_xml(pretty=True), response.as_xml(pretty=True), doc - - def wsdl(self): - """Generate Web Service Description v1.1""" - xml = """ - - %(documentation)s - - - - - - - -""" % {'namespace': self.namespace, 'name': self.name, 'documentation': self.documentation} - wsdl = SimpleXMLElement(xml) - - for method, (function, returns, args, doc) in self.methods.items(): - # create elements: - - def parse_element(name, values, array=False, complex=False): - if not complex: - element = wsdl('wsdl:types')('xsd:schema').add_child('xsd:element') - complex = element.add_child("xsd:complexType") - else: - complex = wsdl('wsdl:types')('xsd:schema').add_child('xsd:complexType') - element = complex - element['name'] = name - if values: - items = values - elif values is None: - items = [('value', None)] - else: - items = [] - if not array and items: - all = complex.add_child("xsd:all") - elif items: - all = complex.add_child("xsd:sequence") - for k, v in items: - e = all.add_child("xsd:element") - e['name'] = k - if array: - e[:] = {'minOccurs': "0", 'maxOccurs': "unbounded"} - if v in TYPE_MAP.keys(): - t = 'xsd:%s' % TYPE_MAP[v] - elif v is None: - t = 'xsd:anyType' - elif isinstance(v, list): - n = "ArrayOf%s%s" % (name, k) - l = [] - for d in v: - l.extend(d.items()) - parse_element(n, l, array=True, complex=True) - t = "tns:%s" % n - elif isinstance(v, dict): - n = "%s%s" % (name, k) - parse_element(n, v.items(), complex=True) - t = "tns:%s" % n - e.add_attribute('type', t) - - parse_element("%s" % method, args and args.items()) - parse_element("%sResponse" % method, returns and returns.items()) - - # create messages: - for m, e in ('Input', ''), ('Output', 'Response'): - message = wsdl.add_child('wsdl:message') - message['name'] = "%s%s" % (method, m) - part = message.add_child("wsdl:part") - part[:] = {'name': 'parameters', - 'element': 'tns:%s%s' % (method, e)} - - # create ports - portType = wsdl.add_child('wsdl:portType') - portType['name'] = "%sPortType" % self.name - for method, (function, returns, args, doc) in self.methods.items(): - op = portType.add_child('wsdl:operation') - op['name'] = method - if doc: - op.add_child("wsdl:documentation", doc) - input = op.add_child("wsdl:input") - input['message'] = "tns:%sInput" % method - output = op.add_child("wsdl:output") - output['message'] = "tns:%sOutput" % method - - # create bindings - binding = wsdl.add_child('wsdl:binding') - binding['name'] = "%sBinding" % self.name - binding['type'] = "tns:%sPortType" % self.name - soapbinding = binding.add_child('soap:binding') - soapbinding['style'] = "document" - soapbinding['transport'] = "http://schemas.xmlsoap.org/soap/http" - for method in self.methods.keys(): - op = binding.add_child('wsdl:operation') - op['name'] = method - soapop = op.add_child('soap:operation') - soapop['soapAction'] = self.action + method - soapop['style'] = 'document' - input = op.add_child("wsdl:input") - ##input.add_attribute('name', "%sInput" % method) - soapbody = input.add_child("soap:body") - soapbody["use"] = "literal" - output = op.add_child("wsdl:output") - ##output.add_attribute('name', "%sOutput" % method) - soapbody = output.add_child("soap:body") - soapbody["use"] = "literal" - - service = wsdl.add_child('wsdl:service') - service["name"] = "%sService" % self.name - service.add_child('wsdl:documentation', text=self.documentation) - port = service.add_child('wsdl:port') - port["name"] = "%s" % self.name - port["binding"] = "tns:%sBinding" % self.name - soapaddress = port.add_child('soap:address') - soapaddress["location"] = self.location - return wsdl.as_xml(pretty=True) - - -class SOAPHandler(BaseHTTPRequestHandler): - - def do_GET(self): - """User viewable help information and wsdl""" - args = self.path[1:].split("?") - if self.path != "/" and args[0] not in self.server.dispatcher.methods.keys(): - self.send_error(404, "Method not found: %s" % args[0]) - else: - if self.path == "/": - # return wsdl if no method supplied - response = self.server.dispatcher.wsdl() - else: - # return supplied method help (?request or ?response messages) - req, res, doc = self.server.dispatcher.help(args[0]) - if len(args) == 1 or args[1] == "request": - response = req - else: - response = res - self.send_response(200) - self.send_header("Content-type", "text/xml") - self.end_headers() - self.wfile.write(response) - - def do_POST(self): - """SOAP POST gateway""" - self.send_response(200) - self.send_header("Content-type", "text/xml") - self.end_headers() - request = self.rfile.read(int(self.headers.getheader('content-length'))) - response = self.server.dispatcher.dispatch(request) - self.wfile.write(response) - - -class WSGISOAPHandler(object): - - def __init__(self, dispatcher): - self.dispatcher = dispatcher - - def __call__(self, environ, start_response): - return self.handler(environ, start_response) - - def handler(self, environ, start_response): - if environ['REQUEST_METHOD'] == 'GET': - return self.do_get(environ, start_response) - elif environ['REQUEST_METHOD'] == 'POST': - return self.do_post(environ, start_response) - else: - start_response('405 Method not allowed', [('Content-Type', 'text/plain')]) - return ['Method not allowed'] - - def do_get(self, environ, start_response): - path = environ.get('PATH_INFO').lstrip('/') - query = environ.get('QUERY_STRING') - if path != "" and path not in self.dispatcher.methods.keys(): - start_response('404 Not Found', [('Content-Type', 'text/plain')]) - return ["Method not found: %s" % path] - elif path == "": - # return wsdl if no method supplied - response = self.dispatcher.wsdl() - else: - # return supplied method help (?request or ?response messages) - req, res, doc = self.dispatcher.help(path) - if len(query) == 0 or query == "request": - response = req - else: - response = res - start_response('200 OK', [('Content-Type', 'text/xml'), ('Content-Length', str(len(response)))]) - return [response] - - def do_post(self, environ, start_response): - length = int(environ['CONTENT_LENGTH']) - request = environ['wsgi.input'].read(length) - response = self.dispatcher.dispatch(request) - start_response('200 OK', [('Content-Type', 'text/xml'), ('Content-Length', str(len(response)))]) - return [response] - - -if __name__ == "__main__": - - dispatcher = SoapDispatcher( - name="PySimpleSoapSample", - location="http://localhost:8008/", - action='http://localhost:8008/', # SOAPAction - namespace="http://example.com/pysimplesoapsamle/", prefix="ns0", - documentation='Example soap service using PySimpleSoap', - trace=True, - ns=True) - - def adder(p, c, dt=None): - """Add several values""" - import datetime - dt = dt + datetime.timedelta(365) - return {'ab': p['a'] + p['b'], 'dd': c[0]['d'] + c[1]['d'], 'dt': dt} - - def dummy(in0): - """Just return input""" - return in0 - - def echo(request): - """Copy request->response (generic, any type)""" - return request.value - - dispatcher.register_function( - 'Adder', adder, - returns={'AddResult': {'ab': int, 'dd': str}}, - args={'p': {'a': int, 'b': int}, 'dt': Date, 'c': [{'d': Decimal}]} - ) - - dispatcher.register_function( - 'Dummy', dummy, - returns={'out0': str}, - args={'in0': str} - ) - - dispatcher.register_function('Echo', echo) - - if '--local' in sys.argv: - - wsdl = dispatcher.wsdl() - - for method, doc in dispatcher.list_methods(): - request, response, doc = dispatcher.help(method) - - if '--serve' in sys.argv: - log.info("Starting server...") - httpd = HTTPServer(("", 8008), SOAPHandler) - httpd.dispatcher = dispatcher - httpd.serve_forever() - - if '--wsgi-serve' in sys.argv: - log.info("Starting wsgi server...") - from wsgiref.simple_server import make_server - application = WSGISOAPHandler(dispatcher) - wsgid = make_server('', 8008, application) - wsgid.serve_forever() - - if '--consume' in sys.argv: - from .client import SoapClient - client = SoapClient( - location="http://localhost:8008/", - action='http://localhost:8008/', # SOAPAction - namespace="http://example.com/sample.wsdl", - soap_ns='soap', - trace=True, - ns=False - ) - p = {'a': 1, 'b': 2} - c = [{'d': '1.20'}, {'d': '2.01'}] - response = client.Adder(p=p, dt='20100724', c=c) - result = response.AddResult - log.info(int(result.ab)) - log.info(str(result.dd)) diff --git a/pysimplesoap/simplexml.py b/pysimplesoap/simplexml.py deleted file mode 100644 index 61538e2..0000000 --- a/pysimplesoap/simplexml.py +++ /dev/null @@ -1,481 +0,0 @@ -#!/usr/bin/python -# -*- coding: utf-8 -*- -# This program is free software; you can redistribute it and/or modify -# it under the terms of the GNU Lesser General Public License as published by the -# Free Software Foundation; either version 3, or (at your option) any later -# version. -# -# This program is distributed in the hope that it will be useful, but -# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTIBILITY -# or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License -# for more details. - -"""Simple XML manipulation""" - - -from __future__ import unicode_literals -import sys -if sys.version > '3': - basestring = str - unicode = str - -import logging -import re -import time -import xml.dom.minidom - -from . import __author__, __copyright__, __license__, __version__ - -# Utility functions used for marshalling, moved aside for readability -from .helpers import TYPE_MAP, TYPE_MARSHAL_FN, TYPE_UNMARSHAL_FN, \ - REVERSE_TYPE_MAP, OrderedDict, Date, Decimal - -log = logging.getLogger(__name__) - - -class SimpleXMLElement(object): - """Simple XML manipulation (simil PHP)""" - - def __init__(self, text=None, elements=None, document=None, - namespace=None, prefix=None, namespaces_map={}, jetty=False): - """ - :param namespaces_map: How to map our namespace prefix to that given by the client; - {prefix: received_prefix} - """ - self.__namespaces_map = namespaces_map - _rx = "|".join(namespaces_map.keys()) # {'external': 'ext', 'model': 'mod'} -> 'external|model' - self.__ns_rx = re.compile(r"^(%s):.*$" % _rx) # And now we build an expression ^(external|model):.*$ - # to find prefixes in all xml nodes i.e.: 1 - # and later change that to 1 - self.__ns = namespace - self.__prefix = prefix - self.__jetty = jetty # special list support - - if text is not None: - try: - self.__document = xml.dom.minidom.parseString(text) - except: - log.error(text) - raise - self.__elements = [self.__document.documentElement] - else: - self.__elements = elements - self.__document = document - - def add_child(self, name, text=None, ns=True): - """Adding a child tag to a node""" - if not ns or self.__ns is False: - ##log.debug('adding %s without namespace', name) - element = self.__document.createElement(name) - else: - ##log.debug('adding %s ns "%s" %s', name, self.__ns, ns) - if isinstance(ns, basestring): - element = self.__document.createElement(name) - element.setAttribute("xmlns", ns) - elif self.__prefix: - element = self.__document.createElementNS(self.__ns, "%s:%s" % (self.__prefix, name)) - else: - element = self.__document.createElementNS(self.__ns, name) - # don't append null tags! - if text is not None: - element.appendChild(self.__document.createTextNode(text)) - self._element.appendChild(element) - return SimpleXMLElement( - elements=[element], - document=self.__document, - namespace=self.__ns, - prefix=self.__prefix, - jetty=self.__jetty, - namespaces_map=self.__namespaces_map - ) - - def __setattr__(self, tag, text): - """Add text child tag node (short form)""" - if tag.startswith("_"): - object.__setattr__(self, tag, text) - else: - ##log.debug('__setattr__(%s, %s)', tag, text) - self.add_child(tag, text) - - def __delattr__(self, tag): - """Remove a child tag (non recursive!)""" - elements = [__element for __element in self._element.childNodes - if __element.nodeType == __element.ELEMENT_NODE] - for element in elements: - self._element.removeChild(element) - - def add_comment(self, data): - """Add an xml comment to this child""" - comment = self.__document.createComment(data) - self._element.appendChild(comment) - - def as_xml(self, filename=None, pretty=False): - """Return the XML representation of the document""" - if not pretty: - return self.__document.toxml('UTF-8') - else: - return self.__document.toprettyxml(encoding='UTF-8') - - def __repr__(self): - """Return the XML representation of this tag""" - # NOTE: do not use self.as_xml('UTF-8') as it returns the whole xml doc - return self._element.toxml('UTF-8') - - def get_name(self): - """Return the tag name of this node""" - return self._element.tagName - - def get_local_name(self): - """Return the tag local name (prefix:name) of this node""" - return self._element.localName - - def get_prefix(self): - """Return the namespace prefix of this node""" - return self._element.prefix - - def get_namespace_uri(self, ns): - """Return the namespace uri for a prefix""" - element = self._element - while element is not None and element.attributes is not None: - try: - return element.attributes['xmlns:%s' % ns].value - except KeyError: - element = element.parentNode - - def attributes(self): - """Return a dict of attributes for this tag""" - #TODO: use slice syntax [:]? - return self._element.attributes - - def __getitem__(self, item): - """Return xml tag attribute value or a slice of attributes (iter)""" - ##log.debug('__getitem__(%s)', item) - if isinstance(item, basestring): - if self._element.hasAttribute(item): - return self._element.attributes[item].value - elif isinstance(item, slice): - # return a list with name:values - return list(self._element.attributes.items())[item] - else: - # return element by index (position) - element = self.__elements[item] - return SimpleXMLElement( - elements=[element], - document=self.__document, - namespace=self.__ns, - prefix=self.__prefix, - jetty=self.__jetty, - namespaces_map=self.__namespaces_map - ) - - def add_attribute(self, name, value): - """Set an attribute value from a string""" - self._element.setAttribute(name, value) - - def __setitem__(self, item, value): - """Set an attribute value""" - if isinstance(item, basestring): - self.add_attribute(item, value) - elif isinstance(item, slice): - # set multiple attributes at once - for k, v in value.items(): - self.add_attribute(k, v) - - def __call__(self, tag=None, ns=None, children=False, root=False, - error=True, ): - """Search (even in child nodes) and return a child tag by name""" - try: - if root: - # return entire document - return SimpleXMLElement( - elements=[self.__document.documentElement], - document=self.__document, - namespace=self.__ns, - prefix=self.__prefix, - jetty=self.__jetty, - namespaces_map=self.__namespaces_map - ) - if tag is None: - # if no name given, iterate over siblings (same level) - return self.__iter__() - if children: - # future: filter children? by ns? - return self.children() - elements = None - if isinstance(tag, int): - # return tag by index - elements = [self.__elements[tag]] - if ns and not elements: - for ns_uri in isinstance(ns, (tuple, list)) and ns or (ns, ): - ##log.debug('searching %s by ns=%s', tag, ns_uri) - elements = self._element.getElementsByTagNameNS(ns_uri, tag) - if elements: - break - if self.__ns and not elements: - ##log.debug('searching %s by ns=%s', tag, self.__ns) - elements = self._element.getElementsByTagNameNS(self.__ns, tag) - if not elements: - ##log.debug('searching %s', tag) - elements = self._element.getElementsByTagName(tag) - if not elements: - ##log.debug(self._element.toxml()) - if error: - raise AttributeError("No elements found") - else: - return - return SimpleXMLElement( - elements=elements, - document=self.__document, - namespace=self.__ns, - prefix=self.__prefix, - jetty=self.__jetty, - namespaces_map=self.__namespaces_map) - except AttributeError as e: - raise AttributeError("Tag not found: %s (%s)" % (tag, e)) - - def __getattr__(self, tag): - """Shortcut for __call__""" - return self.__call__(tag) - - def __iter__(self): - """Iterate over xml tags at this level""" - try: - for __element in self.__elements: - yield SimpleXMLElement( - elements=[__element], - document=self.__document, - namespace=self.__ns, - prefix=self.__prefix, - jetty=self.__jetty, - namespaces_map=self.__namespaces_map) - except: - raise - - def __dir__(self): - """List xml children tags names""" - return [node.tagName for node - in self._element.childNodes - if node.nodeType != node.TEXT_NODE] - - def children(self): - """Return xml children tags element""" - elements = [__element for __element in self._element.childNodes - if __element.nodeType == __element.ELEMENT_NODE] - if not elements: - return None - #raise IndexError("Tag %s has no children" % self._element.tagName) - return SimpleXMLElement( - elements=elements, - document=self.__document, - namespace=self.__ns, - prefix=self.__prefix, - jetty=self.__jetty, - namespaces_map=self.__namespaces_map - ) - - def __len__(self): - """Return element count""" - return len(self.__elements) - - def __contains__(self, item): - """Search for a tag name in this element or child nodes""" - return self._element.getElementsByTagName(item) - - def __unicode__(self): - """Returns the unicode text nodes of the current element""" - if self._element.childNodes: - rc = "" - for node in self._element.childNodes: - if node.nodeType == node.TEXT_NODE: - rc = rc + node.data - return rc - return '' - - def __str__(self): - """Returns the str text nodes of the current element""" - return self.__unicode__() - - def __int__(self): - """Returns the integer value of the current element""" - return int(self.__str__()) - - def __float__(self): - """Returns the float value of the current element""" - try: - return float(self.__str__()) - except: - raise IndexError(self._element.toxml()) - - _element = property(lambda self: self.__elements[0]) - - def unmarshall(self, types, strict=True): - #import pdb; pdb.set_trace() - - """Convert to python values the current serialized xml element""" - # types is a dict of {tag name: convertion function} - # strict=False to use default type conversion if not specified - # example: types={'p': {'a': int,'b': int}, 'c': [{'d':str}]} - # expected xml:

12

holachau - # returnde value: {'p': {'a':1,'b':2}, `'c':[{'d':'hola'},{'d':'chau'}]} - d = {} - for node in self(): - name = str(node.get_local_name()) - ref_name_type = None - # handle multirefs: href="#id0" - if 'href' in node.attributes().keys(): - href = node['href'][1:] - for ref_node in self(root=True)("multiRef"): - if ref_node['id'] == href: - node = ref_node - ref_name_type = ref_node['xsi:type'].split(":")[1] - break - - try: - if isinstance(types, dict): - fn = types[name] - # custom array only in the response (not defined in the WSDL): - # 1): - # Jetty array style support [{k, v}] - for parent in node: - tmp_dict = {} # unmarshall each value & mix - for child in (node.children() or []): - tmp_dict.update(child.unmarshall(fn[0], strict)) - value.append(tmp_dict) - else: # .Net / Java - for child in (children or []): - value.append(child.unmarshall(fn[0], strict)) - - elif isinstance(fn, tuple): - value = [] - _d = {} - children = node.children() - as_dict = len(fn) == 1 and isinstance(fn[0], dict) - - for child in (children and children() or []): # Readability counts - if as_dict: - _d.update(child.unmarshall(fn[0], strict)) # Merging pairs - else: - value.append(child.unmarshall(fn[0], strict)) - if as_dict: - value.append(_d) - - if name in d: - _tmp = list(d[name]) - _tmp.extend(value) - value = tuple(_tmp) - else: - value = tuple(value) - - elif isinstance(fn, dict): - ##if ref_name_type is not None: - ## fn = fn[ref_name_type] - children = node.children() - value = children and children.unmarshall(fn, strict) - else: - if fn is None: # xsd:anyType not unmarshalled - value = node - elif unicode(node) or (fn == str and unicode(node) != ''): - try: - # get special deserialization function (if any) - fn = TYPE_UNMARSHAL_FN.get(fn, fn) - if fn == str: - # always return an unicode object: - # (avoid encoding errors in py<3!) - value = unicode(node) - else: - value = fn(unicode(node)) - except (ValueError, TypeError) as e: - raise ValueError("Tag: %s: %s" % (name, e)) - else: - value = None - d[name] = value - return d - - def _update_ns(self, name): - """Replace the defined namespace alias with tohse used by the client.""" - pref = self.__ns_rx.search(name) - if pref: - pref = pref.groups()[0] - try: - name = name.replace(pref, self.__namespaces_map[pref]) - except KeyError: - log.warning('Unknown namespace alias %s' % name) - return name - - def marshall(self, name, value, add_child=True, add_comments=False, - ns=False, add_children_ns=True): - """Analyze python value and add the serialized XML element using tag name""" - # Change node name to that used by a client - name = self._update_ns(name) - - if isinstance(value, dict): # serialize dict (value) - # for the first parent node, use the document target namespace - # (ns==True) or use the namespace string uri if passed (elements) - child = add_child and self.add_child(name, ns=ns) or self - for k, v in value.items(): - if not add_children_ns: - ns = False - else: - # for children, use the wsdl element target namespace: - ns = getattr(value, 'namespace', None) - child.marshall(k, v, add_comments=add_comments, ns=ns) - elif isinstance(value, tuple): # serialize tuple (value) - child = add_child and self.add_child(name, ns=ns) or self - if not add_children_ns: - ns = False - for k, v in value: - getattr(self, name).marshall(k, v, add_comments=add_comments, ns=ns) - elif isinstance(value, list): # serialize lists - child = self.add_child(name, ns=ns) - if not add_children_ns: - ns = False - if add_comments: - child.add_comment("Repetitive array of:") - for t in value: - child.marshall(name, t, False, add_comments=add_comments, ns=ns) - elif isinstance(value, basestring): # do not convert strings or unicodes - self.add_child(name, value, ns=ns) - elif value is None: # sent a empty tag? - self.add_child(name, ns=ns) - elif value in TYPE_MAP.keys(): - # add commented placeholders for simple tipes (for examples/help only) - child = self.add_child(name, ns=ns) - child.add_comment(TYPE_MAP[value]) - else: # the rest of object types are converted to string - # get special serialization function (if any) - fn = TYPE_MARSHAL_FN.get(type(value), str) - self.add_child(name, fn(value), ns=ns) - - def import_node(self, other): - x = self.__document.importNode(other._element, True) # deep copy - self._element.appendChild(x) diff --git a/pysimplesoap/transport.py b/pysimplesoap/transport.py deleted file mode 100644 index 9e0e6c4..0000000 --- a/pysimplesoap/transport.py +++ /dev/null @@ -1,274 +0,0 @@ -#!/usr/bin/python -# -*- coding: utf-8 -*- -# This program is free software; you can redistribute it and/or modify -# it under the terms of the GNU Lesser General Public License as published by the -# Free Software Foundation; either version 3, or (at your option) any later -# version. -# -# This program is distributed in the hope that it will be useful, but -# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTIBILITY -# or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License -# for more details. - -"""Pythonic simple SOAP Client transport""" - - -import logging -import sys -try: - import urllib2 - from cookielib import CookieJar -except ImportError: - from urllib import request as urllib2 - from http.cookiejar import CookieJar - -from . import __author__, __copyright__, __license__, __version__, TIMEOUT -from .simplexml import SimpleXMLElement, TYPE_MAP, OrderedDict - -log = logging.getLogger(__name__) - -# -# Socket wrapper to enable socket.TCP_NODELAY - this greatly speeds up transactions in Linux -# WARNING: this will modify the standard library socket module, use with care! -# TODO: implement this as a transport faciliy -# (to pass options directly to httplib2 or pycurl) -# be aware of metaclasses and socks.py (SocksiPy) used by httplib2 - -if False: - import socket - realsocket = socket.socket - def socketwrap(family=socket.AF_INET, type=socket.SOCK_STREAM, proto=0): - sockobj = realsocket(family, type, proto) - if type == socket.SOCK_STREAM: - sockobj.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) - return sockobj - socket.socket = socketwrap - -# -# We store metadata about what available transport mechanisms we have available. -# -_http_connectors = {} # libname: classimpl mapping -_http_facilities = {} # functionalitylabel: [sequence of libname] mapping - - -class TransportBase: - @classmethod - def supports_feature(cls, feature_name): - return cls._wrapper_name in _http_facilities[feature_name] - -# -# httplib2 support. -# -try: - import httplib2 - #~ if sys.version > '3' and httplib2.__version__ <= "0.7.7": - if sys.version > '3': - import http.client - # httplib2 workaround: check_hostname needs a SSL context with either - # CERT_OPTIONAL or CERT_REQUIRED - # see https://code.google.com/p/httplib2/issues/detail?id=173 - orig__init__ = http.client.HTTPSConnection.__init__ - def fixer(self, host, port, key_file, cert_file, timeout, context, - check_hostname, *args, **kwargs): - chk = kwargs.get('disable_ssl_certificate_validation', True) ^ True - orig__init__(self, host, port=port, key_file=key_file, - cert_file=cert_file, timeout=timeout, context=context, - check_hostname=chk) - http.client.HTTPSConnection.__init__ = fixer -except ImportError: - TIMEOUT = None # timeout not supported by urllib2 - pass -else: - class Httplib2Transport(httplib2.Http, TransportBase): - _wrapper_version = "httplib2 %s" % httplib2.__version__ - _wrapper_name = 'httplib2' - - def __init__(self, timeout, proxy=None, cacert=None, sessions=False): - ##httplib2.debuglevel=4 - kwargs = {} - if proxy: - import socks - kwargs['proxy_info'] = httplib2.ProxyInfo(proxy_type=socks.PROXY_TYPE_HTTP, **proxy) - log.info("using proxy %s" % proxy) - - # set optional parameters according supported httplib2 version - if httplib2.__version__ >= '0.3.0': - kwargs['timeout'] = timeout - if httplib2.__version__ >= '0.7.0': - kwargs['disable_ssl_certificate_validation'] = cacert is None - kwargs['ca_certs'] = cacert - httplib2.Http.__init__(self, **kwargs) - - _http_connectors['httplib2'] = Httplib2Transport - _http_facilities.setdefault('proxy', []).append('httplib2') - _http_facilities.setdefault('cacert', []).append('httplib2') - - import inspect - if 'timeout' in inspect.getargspec(httplib2.Http.__init__)[0]: - _http_facilities.setdefault('timeout', []).append('httplib2') - - -# -# urllib2 support. -# -class urllib2Transport(TransportBase): - _wrapper_version = "urllib2 %s" % urllib2.__version__ - _wrapper_name = 'urllib2' - - def __init__(self, timeout=None, proxy=None, cacert=None, sessions=False): - if (timeout is not None) and not self.supports_feature('timeout'): - raise RuntimeError('timeout is not supported with urllib2 transport') - if proxy: - raise RuntimeError('proxy is not supported with urllib2 transport') - if cacert: - raise RuntimeError('cacert is not support with urllib2 transport') - - self.request_opener = urllib2.urlopen - if sessions: - opener = urllib2.build_opener(urllib2.HTTPCookieProcessor(CookieJar())) - self.request_opener = opener.open - - self._timeout = timeout - - def request(self, url, method="GET", body=None, headers={}): - req = urllib2.Request(url, body, headers) - try: - f = self.request_opener(req, timeout=self._timeout) - return f.info(), f.read() - except urllib2.HTTPError as f: - if f.code != 500: - raise - return f.info(), f.read() - -_http_connectors['urllib2'] = urllib2Transport -_http_facilities.setdefault('sessions', []).append('urllib2') - -import sys -if sys.version_info >= (2, 6): - _http_facilities.setdefault('timeout', []).append('urllib2') -del sys - -# -# pycurl support. -# experimental: pycurl seems faster + better proxy support (NTLM) + ssl features -# -try: - import pycurl -except ImportError: - pass -else: - try: - from cStringIO import StringIO - except ImportError: - try: - from StringIO import StringIO - except ImportError: - from io import StringIO - - class pycurlTransport(TransportBase): - _wrapper_version = pycurl.version - _wrapper_name = 'pycurl' - - def __init__(self, timeout, proxy=None, cacert=None, sessions=False): - self.timeout = timeout - self.proxy = proxy or {} - self.cacert = cacert - - def request(self, url, method, body, headers): - c = pycurl.Curl() - c.setopt(pycurl.URL, url) - if 'proxy_host' in self.proxy: - c.setopt(pycurl.PROXY, self.proxy['proxy_host']) - if 'proxy_port' in self.proxy: - c.setopt(pycurl.PROXYPORT, self.proxy['proxy_port']) - if 'proxy_user' in self.proxy: - c.setopt(pycurl.PROXYUSERPWD, "%(proxy_user)s:%(proxy_pass)s" % self.proxy) - self.buf = StringIO() - c.setopt(pycurl.WRITEFUNCTION, self.buf.write) - #c.setopt(pycurl.READFUNCTION, self.read) - #self.body = StringIO(body) - #c.setopt(pycurl.HEADERFUNCTION, self.header) - if self.cacert: - c.setopt(c.CAINFO, self.cacert) - c.setopt(pycurl.SSL_VERIFYPEER, self.cacert and 1 or 0) - c.setopt(pycurl.SSL_VERIFYHOST, self.cacert and 2 or 0) - c.setopt(pycurl.CONNECTTIMEOUT, self.timeout / 6) - c.setopt(pycurl.TIMEOUT, self.timeout) - if method == 'POST': - c.setopt(pycurl.POST, 1) - c.setopt(pycurl.POSTFIELDS, body) - if headers: - hdrs = ['%s: %s' % (k, v) for k, v in headers.items()] - log.debug(hdrs) - c.setopt(pycurl.HTTPHEADER, hdrs) - c.perform() - c.close() - return {}, self.buf.getvalue() - - _http_connectors['pycurl'] = pycurlTransport - _http_facilities.setdefault('proxy', []).append('pycurl') - _http_facilities.setdefault('cacert', []).append('pycurl') - _http_facilities.setdefault('timeout', []).append('pycurl') - - -class DummyTransport: - """Testing class to load a xml response""" - - def __init__(self, xml_response): - self.xml_response = xml_response - - def request(self, location, method, body, headers): - log.debug("%s %s", method, location) - log.debug(headers) - log.debug(body) - return {}, self.xml_response - - -def get_http_wrapper(library=None, features=[]): - # If we are asked for a specific library, return it. - if library is not None: - try: - return _http_connectors[library] - except KeyError: - raise RuntimeError('%s transport is not available' % (library,)) - - # If we haven't been asked for a specific feature either, then just return our favourite - # implementation. - if not features: - return _http_connectors.get('httplib2', _http_connectors['urllib2']) - - # If we are asked for a connector which supports the given features, then we will - # try that. - current_candidates = _http_connectors.keys() - new_candidates = [] - for feature in features: - for candidate in current_candidates: - if candidate in _http_facilities.get(feature, []): - new_candidates.append(candidate) - current_candidates = new_candidates - new_candidates = [] - - # Return the first candidate in the list. - try: - candidate_name = current_candidates[0] - except IndexError: - raise RuntimeError("no transport available which supports these features: %s" % (features,)) - else: - return _http_connectors[candidate_name] - - -def set_http_wrapper(library=None, features=[]): - """Set a suitable HTTP connection wrapper.""" - global Http - Http = get_http_wrapper(library, features) - return Http - - -def get_Http(): - """Return current transport class""" - global Http - return Http - - -# define the default HTTP connection class (it can be changed at runtime!): -set_http_wrapper() diff --git a/setup.py b/setup.py index 934cd91..6addb6d 100644 --- a/setup.py +++ b/setup.py @@ -11,7 +11,7 @@ author='Mauricio Baeza', author_email='correopublico@mauriciobaeza.org', url='https://facturalibre.net/servicios/', - install_requires=['pygubu', 'selenium'], + install_requires=['pygubu', 'selenium', 'pyqrcode', 'pysimplesoap'], py_modules=['pyutil', 'values', 'template'], scripts=['admincfdi.py']) From 0147b26ae7b2a07c9a2eb677f296b3170fe10bed Mon Sep 17 00:00:00 2001 From: Sergio E Date: Mon, 9 Mar 2015 01:24:16 -0600 Subject: [PATCH 046/167] Mover archivos a subcarpeta admincfdi MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit El proyecto necesita estar organizado de forma que la definición del modulo no este en la raíz del proyecto, de lo contrario setup.py pone los archivos en raíz de la carpeta destino. --- admincfdi.py => admin-cfdi | 0 __init__.py => admincfdi/__init__.py | 0 {img => admincfdi/img}/calc.gif | Bin {img => admincfdi/img}/calc.png | Bin {img => admincfdi/img}/csv.gif | Bin {img => admincfdi/img}/csv.png | Bin {img => admincfdi/img}/delete.gif | Bin {img => admincfdi/img}/delete.png | Bin {img => admincfdi/img}/down.gif | Bin {img => admincfdi/img}/down.png | Bin {img => admincfdi/img}/exit.gif | Bin {img => admincfdi/img}/exit.png | Bin {img => admincfdi/img}/favicon.gif | Bin {img => admincfdi/img}/favicon.png | Bin {img => admincfdi/img}/folder.gif | Bin {img => admincfdi/img}/folder.png | Bin {img => admincfdi/img}/pdf.gif | Bin {img => admincfdi/img}/pdf.png | Bin {img => admincfdi/img}/report.gif | Bin {img => admincfdi/img}/report.png | Bin {img => admincfdi/img}/save.gif | Bin {img => admincfdi/img}/save.png | Bin {img => admincfdi/img}/xml.gif | Bin {img => admincfdi/img}/xml.png | Bin pyutil.py => admincfdi/pyutil.py | 0 {ui => admincfdi/ui}/mainwindow.ui | 0 values.py => admincfdi/values.py | 0 27 files changed, 0 insertions(+), 0 deletions(-) rename admincfdi.py => admin-cfdi (100%) rename __init__.py => admincfdi/__init__.py (100%) rename {img => admincfdi/img}/calc.gif (100%) rename {img => admincfdi/img}/calc.png (100%) rename {img => admincfdi/img}/csv.gif (100%) rename {img => admincfdi/img}/csv.png (100%) rename {img => admincfdi/img}/delete.gif (100%) rename {img => admincfdi/img}/delete.png (100%) rename {img => admincfdi/img}/down.gif (100%) rename {img => admincfdi/img}/down.png (100%) rename {img => admincfdi/img}/exit.gif (100%) rename {img => admincfdi/img}/exit.png (100%) rename {img => admincfdi/img}/favicon.gif (100%) rename {img => admincfdi/img}/favicon.png (100%) rename {img => admincfdi/img}/folder.gif (100%) rename {img => admincfdi/img}/folder.png (100%) rename {img => admincfdi/img}/pdf.gif (100%) rename {img => admincfdi/img}/pdf.png (100%) rename {img => admincfdi/img}/report.gif (100%) rename {img => admincfdi/img}/report.png (100%) rename {img => admincfdi/img}/save.gif (100%) rename {img => admincfdi/img}/save.png (100%) rename {img => admincfdi/img}/xml.gif (100%) rename {img => admincfdi/img}/xml.png (100%) rename pyutil.py => admincfdi/pyutil.py (100%) rename {ui => admincfdi/ui}/mainwindow.ui (100%) rename values.py => admincfdi/values.py (100%) diff --git a/admincfdi.py b/admin-cfdi similarity index 100% rename from admincfdi.py rename to admin-cfdi diff --git a/__init__.py b/admincfdi/__init__.py similarity index 100% rename from __init__.py rename to admincfdi/__init__.py diff --git a/img/calc.gif b/admincfdi/img/calc.gif similarity index 100% rename from img/calc.gif rename to admincfdi/img/calc.gif diff --git a/img/calc.png b/admincfdi/img/calc.png similarity index 100% rename from img/calc.png rename to admincfdi/img/calc.png diff --git a/img/csv.gif b/admincfdi/img/csv.gif similarity index 100% rename from img/csv.gif rename to admincfdi/img/csv.gif diff --git a/img/csv.png b/admincfdi/img/csv.png similarity index 100% rename from img/csv.png rename to admincfdi/img/csv.png diff --git a/img/delete.gif b/admincfdi/img/delete.gif similarity index 100% rename from img/delete.gif rename to admincfdi/img/delete.gif diff --git a/img/delete.png b/admincfdi/img/delete.png similarity index 100% rename from img/delete.png rename to admincfdi/img/delete.png diff --git a/img/down.gif b/admincfdi/img/down.gif similarity index 100% rename from img/down.gif rename to admincfdi/img/down.gif diff --git a/img/down.png b/admincfdi/img/down.png similarity index 100% rename from img/down.png rename to admincfdi/img/down.png diff --git a/img/exit.gif b/admincfdi/img/exit.gif similarity index 100% rename from img/exit.gif rename to admincfdi/img/exit.gif diff --git a/img/exit.png b/admincfdi/img/exit.png similarity index 100% rename from img/exit.png rename to admincfdi/img/exit.png diff --git a/img/favicon.gif b/admincfdi/img/favicon.gif similarity index 100% rename from img/favicon.gif rename to admincfdi/img/favicon.gif diff --git a/img/favicon.png b/admincfdi/img/favicon.png similarity index 100% rename from img/favicon.png rename to admincfdi/img/favicon.png diff --git a/img/folder.gif b/admincfdi/img/folder.gif similarity index 100% rename from img/folder.gif rename to admincfdi/img/folder.gif diff --git a/img/folder.png b/admincfdi/img/folder.png similarity index 100% rename from img/folder.png rename to admincfdi/img/folder.png diff --git a/img/pdf.gif b/admincfdi/img/pdf.gif similarity index 100% rename from img/pdf.gif rename to admincfdi/img/pdf.gif diff --git a/img/pdf.png b/admincfdi/img/pdf.png similarity index 100% rename from img/pdf.png rename to admincfdi/img/pdf.png diff --git a/img/report.gif b/admincfdi/img/report.gif similarity index 100% rename from img/report.gif rename to admincfdi/img/report.gif diff --git a/img/report.png b/admincfdi/img/report.png similarity index 100% rename from img/report.png rename to admincfdi/img/report.png diff --git a/img/save.gif b/admincfdi/img/save.gif similarity index 100% rename from img/save.gif rename to admincfdi/img/save.gif diff --git a/img/save.png b/admincfdi/img/save.png similarity index 100% rename from img/save.png rename to admincfdi/img/save.png diff --git a/img/xml.gif b/admincfdi/img/xml.gif similarity index 100% rename from img/xml.gif rename to admincfdi/img/xml.gif diff --git a/img/xml.png b/admincfdi/img/xml.png similarity index 100% rename from img/xml.png rename to admincfdi/img/xml.png diff --git a/pyutil.py b/admincfdi/pyutil.py similarity index 100% rename from pyutil.py rename to admincfdi/pyutil.py diff --git a/ui/mainwindow.ui b/admincfdi/ui/mainwindow.ui similarity index 100% rename from ui/mainwindow.ui rename to admincfdi/ui/mainwindow.ui diff --git a/values.py b/admincfdi/values.py similarity index 100% rename from values.py rename to admincfdi/values.py From a7e11a62db31709ad66ffdef7ab877546b9b948e Mon Sep 17 00:00:00 2001 From: Sergio E Date: Mon, 9 Mar 2015 01:37:16 -0600 Subject: [PATCH 047/167] Referencias a archivos externos relativas MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Las imagenes y layouts son ahora referenciadas con respecto a la ubicación del modulo, no al path de ejecución. --- admincfdi/values.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/admincfdi/values.py b/admincfdi/values.py index a7b3224..d2ae521 100644 --- a/admincfdi/values.py +++ b/admincfdi/values.py @@ -23,7 +23,7 @@ class Global(object): OS = sys.platform MAIN = 'mainwindow' TITLE = 'Admin CFDI - Factura Libre' - CWD = os.getcwd() + CWD = os.path.dirname(__file__) PATHS = { 'current': CWD, 'img': os.path.join(CWD, 'img'), From af23aa61117b771673158d35b1ba75aa1ddcc448 Mon Sep 17 00:00:00 2001 From: Sergio E Date: Mon, 9 Mar 2015 01:43:58 -0600 Subject: [PATCH 048/167] Usar nueva estructura del modulo --- admin-cfdi | 8 ++------ setup.py | 5 +++-- 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/admin-cfdi b/admin-cfdi index c2e1a31..201131e 100644 --- a/admin-cfdi +++ b/admin-cfdi @@ -14,12 +14,8 @@ import tkinter as tk import pygubu from selenium import webdriver -from pyutil import Util -from pyutil import Mail -from pyutil import LibO -from pyutil import CFDIPDF -from pyutil import DescargaSAT -from values import Global +from admincfdi.pyutil import Util, Mail, LibO, CFDIPDF +from admincfdi.values import Global class Application(pygubu.TkApplication): diff --git a/setup.py b/setup.py index 6addb6d..adcba7b 100644 --- a/setup.py +++ b/setup.py @@ -11,8 +11,9 @@ author='Mauricio Baeza', author_email='correopublico@mauriciobaeza.org', url='https://facturalibre.net/servicios/', + packages=find_packages(), install_requires=['pygubu', 'selenium', 'pyqrcode', 'pysimplesoap'], - py_modules=['pyutil', 'values', 'template'], - scripts=['admincfdi.py']) + package_data = {'': ['img/*.png', 'img/*.gif', 'ui/*']}, + scripts=['admin-cfdi']) # vim: ts=4 et sw=4 From 59989e3f607398011d5a728dc07da491f11f81db Mon Sep 17 00:00:00 2001 From: Sergio E Date: Mon, 9 Mar 2015 01:47:07 -0600 Subject: [PATCH 049/167] Usar setuptools en lugar de distuils --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index adcba7b..9506970 100644 --- a/setup.py +++ b/setup.py @@ -2,7 +2,7 @@ # -*- coding: utf-8 -*- ''' Setup for Admin CFDI ''' -from distutils.core import setup +from setuptools import setup, find_packages setup(name='Admin-CFDI', version='0.2.6', From ab50d84c99cecbb5654cc451f9f8a2765156dd73 Mon Sep 17 00:00:00 2001 From: Sergio E Date: Mon, 9 Mar 2015 01:47:46 -0600 Subject: [PATCH 050/167] =?UTF-8?q?Comentar=20la=20inclusi=C3=B3n=20de=20d?= =?UTF-8?q?ependencias=20de=20LibreOffice?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- admincfdi/pyutil.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/admincfdi/pyutil.py b/admincfdi/pyutil.py index e191c22..7c498c3 100644 --- a/admincfdi/pyutil.py +++ b/admincfdi/pyutil.py @@ -51,11 +51,11 @@ if sys.platform == WIN: from win32com.client import Dispatch -elif sys.platform == LINUX: - import uno - from com.sun.star.beans import PropertyValue - from com.sun.star.beans.PropertyState import DIRECT_VALUE - from com.sun.star.awt import Size +#elif sys.platform == LINUX: +# import uno +# from com.sun.star.beans import PropertyValue +# from com.sun.star.beans.PropertyState import DIRECT_VALUE +# from com.sun.star.awt import Size class SAT(object): From facc31e8ece6d6e181db8a7cf0831d0c7f777596 Mon Sep 17 00:00:00 2001 From: Sergio E Date: Mon, 23 Mar 2015 00:01:00 -0600 Subject: [PATCH 051/167] Agregar descarga-cfdi a setup.py --- descarga.py => descarga-cfdi | 0 setup.py | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) rename descarga.py => descarga-cfdi (100%) diff --git a/descarga.py b/descarga-cfdi similarity index 100% rename from descarga.py rename to descarga-cfdi diff --git a/setup.py b/setup.py index 9506970..166af13 100644 --- a/setup.py +++ b/setup.py @@ -14,6 +14,6 @@ packages=find_packages(), install_requires=['pygubu', 'selenium', 'pyqrcode', 'pysimplesoap'], package_data = {'': ['img/*.png', 'img/*.gif', 'ui/*']}, - scripts=['admin-cfdi']) + scripts=['admin-cfdi','descarga-cfdi']) # vim: ts=4 et sw=4 From c21f0694d46ac2e0d94c8d584557530f47a05b19 Mon Sep 17 00:00:00 2001 From: Sergio E Date: Mon, 23 Mar 2015 00:40:44 -0600 Subject: [PATCH 052/167] Actualizar referencias a modulo admincfdi --- admincfdi/pyutil.py | 4 +++- descarga-cfdi | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/admincfdi/pyutil.py b/admincfdi/pyutil.py index 7c498c3..04ffdb5 100644 --- a/admincfdi/pyutil.py +++ b/admincfdi/pyutil.py @@ -36,8 +36,10 @@ from tkinter import messagebox from pysimplesoap.client import SoapClient, SoapFault from selenium import webdriver -from values import Global from fpdf import FPDF +from admincfdi.values import Global + + try: from subprocess import DEVNULL except ImportError: diff --git a/descarga-cfdi b/descarga-cfdi index 496d095..4499037 100755 --- a/descarga-cfdi +++ b/descarga-cfdi @@ -4,7 +4,7 @@ import datetime import os import getpass -from pyutil import DescargaSAT +from admincfdi.pyutil import DescargaSAT def process_command_line_arguments(): From d7080bdd8bce3d6227245883b5ded9fbf11e4272 Mon Sep 17 00:00:00 2001 From: Sergio E Date: Mon, 23 Mar 2015 01:11:58 -0600 Subject: [PATCH 053/167] Agregar cabecera a descarga --- descarga-cfdi | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/descarga-cfdi b/descarga-cfdi index 4499037..6b77387 100755 --- a/descarga-cfdi +++ b/descarga-cfdi @@ -1,3 +1,17 @@ +#!/usr/bin/env python3 +#! coding: utf-8 + +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by the +# Free Software Foundation; either version 3, or (at your option) any later +# version. +# +# This program is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTIBILITY +# or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License +# for more details. + + import argparse import time import datetime From 846375d24affa52ded711d5626871f8fbb8e15ad Mon Sep 17 00:00:00 2001 From: Sergio E Date: Fri, 27 Mar 2015 02:10:07 -0600 Subject: [PATCH 054/167] Actualizar a 0.2.7 --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 166af13..e7b284a 100644 --- a/setup.py +++ b/setup.py @@ -5,7 +5,7 @@ from setuptools import setup, find_packages setup(name='Admin-CFDI', - version='0.2.6', + version='0.2.7', description='Herramienta para administracion de CFDIs', license='GPL 3.0', author='Mauricio Baeza', From 51cc33e542ac3d2b1c1f1e1c60f2bd494e4fa3d3 Mon Sep 17 00:00:00 2001 From: Sergio E Date: Fri, 27 Mar 2015 03:31:56 -0600 Subject: [PATCH 055/167] =?UTF-8?q?Revert=20"Comentar=20la=20inclusi=C3=B3?= =?UTF-8?q?n=20de=20dependencias=20de=20LibreOffice"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 608e29d1b71525e27559bd54521bfef5a35bafd9. --- admincfdi/pyutil.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/admincfdi/pyutil.py b/admincfdi/pyutil.py index 04ffdb5..4c41dcf 100644 --- a/admincfdi/pyutil.py +++ b/admincfdi/pyutil.py @@ -53,11 +53,11 @@ if sys.platform == WIN: from win32com.client import Dispatch -#elif sys.platform == LINUX: -# import uno -# from com.sun.star.beans import PropertyValue -# from com.sun.star.beans.PropertyState import DIRECT_VALUE -# from com.sun.star.awt import Size +elif sys.platform == LINUX: + import uno + from com.sun.star.beans import PropertyValue + from com.sun.star.beans.PropertyState import DIRECT_VALUE + from com.sun.star.awt import Size class SAT(object): From 5dcd00c60a09c53846917192f08f78aace8be55f Mon Sep 17 00:00:00 2001 From: Sergio E Gutierrez Date: Sun, 29 Mar 2015 15:09:43 -0600 Subject: [PATCH 056/167] Agregar DescargaSAT a admin-cfdi --- admin-cfdi | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/admin-cfdi b/admin-cfdi index 201131e..43da7eb 100644 --- a/admin-cfdi +++ b/admin-cfdi @@ -14,7 +14,7 @@ import tkinter as tk import pygubu from selenium import webdriver -from admincfdi.pyutil import Util, Mail, LibO, CFDIPDF +from admincfdi.pyutil import Util, Mail, LibO, CFDIPDF, DescargaSAT from admincfdi.values import Global From ae96d359d7b6a4649e0916509e67e2bf7ebfdfc3 Mon Sep 17 00:00:00 2001 From: Sergio E Gutierrez Date: Sun, 29 Mar 2015 18:20:25 -0600 Subject: [PATCH 057/167] Agregar fpdf a setup.py --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index e7b284a..f875c91 100644 --- a/setup.py +++ b/setup.py @@ -12,7 +12,7 @@ author_email='correopublico@mauriciobaeza.org', url='https://facturalibre.net/servicios/', packages=find_packages(), - install_requires=['pygubu', 'selenium', 'pyqrcode', 'pysimplesoap'], + install_requires=['fpdf','pygubu', 'selenium', 'pyqrcode', 'pysimplesoap'], package_data = {'': ['img/*.png', 'img/*.gif', 'ui/*']}, scripts=['admin-cfdi','descarga-cfdi']) From 0255eecb4f743cee1827323eb135423567b25e17 Mon Sep 17 00:00:00 2001 From: Sergio E Gutierrez Date: Sun, 29 Mar 2015 18:46:27 -0600 Subject: [PATCH 058/167] Actualizar referencia a pyutil --- xml2pdf.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/xml2pdf.py b/xml2pdf.py index ec5a8c8..8fd3819 100755 --- a/xml2pdf.py +++ b/xml2pdf.py @@ -1,7 +1,7 @@ import argparse -from pyutil import CSVPDF -from pyutil import Util +from admincfdi.pyutil import CSVPDF +from admincfdi.pyutil import Util def process_command_line_arguments(): parser = argparse.ArgumentParser( From 99dc5dbde8e4fde3b36f6f3e0ef460dff4bfb616 Mon Sep 17 00:00:00 2001 From: Sergio E Gutierrez Date: Sun, 29 Mar 2015 18:50:00 -0600 Subject: [PATCH 059/167] Renombrando cfdi2pdf --- xml2pdf.py => cfdi2pdf | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename xml2pdf.py => cfdi2pdf (100%) diff --git a/xml2pdf.py b/cfdi2pdf similarity index 100% rename from xml2pdf.py rename to cfdi2pdf From f91a31ba5f5ceca7617a0197e7aa6d1f8e54f782 Mon Sep 17 00:00:00 2001 From: Sergio E Gutierrez Date: Sun, 29 Mar 2015 18:52:52 -0600 Subject: [PATCH 060/167] Agregar shebang a cfdi2pdf --- cfdi2pdf | 1 + 1 file changed, 1 insertion(+) diff --git a/cfdi2pdf b/cfdi2pdf index 8fd3819..9f08cff 100755 --- a/cfdi2pdf +++ b/cfdi2pdf @@ -1,3 +1,4 @@ +#!/usr/bin/env python3 import argparse from admincfdi.pyutil import CSVPDF From 2767ad83f2c4eef0ea48fb8a614ecb7e1222aef9 Mon Sep 17 00:00:00 2001 From: Sergio E Gutierrez Date: Sun, 29 Mar 2015 18:56:35 -0600 Subject: [PATCH 061/167] Mover template a la carpeta de modulo --- {template => admincfdi/template}/default.csv | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename {template => admincfdi/template}/default.csv (100%) diff --git a/template/default.csv b/admincfdi/template/default.csv similarity index 100% rename from template/default.csv rename to admincfdi/template/default.csv From 38a40d6823a3b48e10f4f47fba2a7137e72ed391 Mon Sep 17 00:00:00 2001 From: Sergio E Gutierrez Date: Sun, 29 Mar 2015 19:00:55 -0600 Subject: [PATCH 062/167] Agregar dependencia pypng --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index f875c91..30d8b09 100644 --- a/setup.py +++ b/setup.py @@ -12,7 +12,7 @@ author_email='correopublico@mauriciobaeza.org', url='https://facturalibre.net/servicios/', packages=find_packages(), - install_requires=['fpdf','pygubu', 'selenium', 'pyqrcode', 'pysimplesoap'], + install_requires=['pypng', 'fpdf', 'pygubu', 'selenium', 'pyqrcode', 'pysimplesoap'], package_data = {'': ['img/*.png', 'img/*.gif', 'ui/*']}, scripts=['admin-cfdi','descarga-cfdi']) From dee6b334ff9fb324ed859db80acd16b09a8b86d6 Mon Sep 17 00:00:00 2001 From: Sergio E Gutierrez Date: Sun, 29 Mar 2015 19:07:49 -0600 Subject: [PATCH 063/167] Agregar carpeta template a setup.py --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 30d8b09..4414c2e 100644 --- a/setup.py +++ b/setup.py @@ -13,7 +13,7 @@ url='https://facturalibre.net/servicios/', packages=find_packages(), install_requires=['pypng', 'fpdf', 'pygubu', 'selenium', 'pyqrcode', 'pysimplesoap'], - package_data = {'': ['img/*.png', 'img/*.gif', 'ui/*']}, + package_data = {'': ['img/*.png', 'img/*.gif', 'ui/*', 'template/*']}, scripts=['admin-cfdi','descarga-cfdi']) # vim: ts=4 et sw=4 From edbece44d5cad483f7377540cc099de2364508b9 Mon Sep 17 00:00:00 2001 From: Sergio E Gutierrez Date: Sun, 29 Mar 2015 19:23:45 -0600 Subject: [PATCH 064/167] Agregar cfdi2pdf a setup.py como script --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 4414c2e..46df802 100644 --- a/setup.py +++ b/setup.py @@ -14,6 +14,6 @@ packages=find_packages(), install_requires=['pypng', 'fpdf', 'pygubu', 'selenium', 'pyqrcode', 'pysimplesoap'], package_data = {'': ['img/*.png', 'img/*.gif', 'ui/*', 'template/*']}, - scripts=['admin-cfdi','descarga-cfdi']) + scripts=['admin-cfdi','descarga-cfdi', 'cfdi2pdf']) # vim: ts=4 et sw=4 From fa815bcf577f99fe49014a6195d4625e29d77ec5 Mon Sep 17 00:00:00 2001 From: Mauricio Baeza Date: Sun, 29 Mar 2015 22:19:18 -0600 Subject: [PATCH 065/167] Agregar archivo contributors --- contributors.txt | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 contributors.txt diff --git a/contributors.txt b/contributors.txt new file mode 100644 index 0000000..10033b5 --- /dev/null +++ b/contributors.txt @@ -0,0 +1,5 @@ +Mauricio Baeza +Miguel Trujillo +Patricio Paez +Sergio Gutierrez +Jonathan Lopez From 58ba7b91201da7bb2e7266a70c165867e654ea80 Mon Sep 17 00:00:00 2001 From: Mauricio Baeza Date: Sun, 29 Mar 2015 22:25:39 -0600 Subject: [PATCH 066/167] Actualizar README --- README.rst | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/README.rst b/README.rst index af61012..b3b6b25 100644 --- a/README.rst +++ b/README.rst @@ -2,13 +2,13 @@ admin-cfdi ========== :Autores: - Maurico Baeza + Ver archivo contributors.txt :Fecha: 04/12/2014 -:Versión: - 0.2.2 +:Ultima Versión: + 0.2.6 Descripción @@ -27,18 +27,17 @@ Pequeño sistema para administrar CFDIs; facturas electrónicas de México. Entr Requerimientos -------------- -* Python 3.4 +* Python 3.2+ * Tk si usas Linux. Si usas Windows, ya lo integra Python. * Firefox para la automatización de la descarga del SAT. * Selenium para la automatización de la descarga del SAT. * PyGubu para la interfaz gráfica. -* ReportLab si usas una plantilla JSON (por implementar, aunque podemos usar pyfpdf; mucho más sencilla). * LibreOffice si usas la plantilla ODS. * Extensiones win32 para Python si usas Windows. Instalación ----------- -Si tienes instalado correctamente Python 3.4, puedes instalar con Pip. +Si tienes instalado correctamente Python 3.2+, puedes instalar con Pip. GNU & LInux ########### From b1bb12890bb57093cae338445dc9a0ce5b4352bb Mon Sep 17 00:00:00 2001 From: Mauricio Baeza Date: Sun, 29 Mar 2015 23:08:59 -0600 Subject: [PATCH 067/167] Cambiado los derechos de Mauricio Baeza a Python Cabal --- docs/conf.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/conf.py b/docs/conf.py index 839bcb7..d47fa42 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -45,7 +45,7 @@ # General information about the project. project = 'admin-cfdi' -copyright = '2015, Mauricio Baeza' +copyright = '2015, Python Cabal' # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the From 7ef84ce34a8a310e6fe5478be6e80da93dcc761e Mon Sep 17 00:00:00 2001 From: Mauricio Baeza Date: Sun, 29 Mar 2015 23:09:39 -0600 Subject: [PATCH 068/167] =?UTF-8?q?Modificada=20la=20instroducci=C3=B3n?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/intro.rst | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/docs/intro.rst b/docs/intro.rst index b0d6ce9..1b7611a 100644 --- a/docs/intro.rst +++ b/docs/intro.rst @@ -2,6 +2,9 @@ Introducción ============ -:term:`admin-cfdi` es una aplicación para descargar -y administrar documentos :term:`CFDI` emitidos por el -:term:`SAT`. +:term:`admin-cfdi` es una aplicación desarrollada en `Python`_ para descargar +documentos :term:`CFDI` (facturas electrónicas) directamente del :term:`SAT`, +permite también, descargar desde correos electrónicos, validarlos y administrarlos. + + +.. _Python: http://python.org/ From 920d5fe306640094a26e38b7c9f2382d187edf9a Mon Sep 17 00:00:00 2001 From: Mauricio Baeza Date: Sun, 29 Mar 2015 23:10:26 -0600 Subject: [PATCH 069/167] Corregido el termino CFDI del glosario --- docs/glosario.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/glosario.rst b/docs/glosario.rst index 495a527..8bb6c80 100644 --- a/docs/glosario.rst +++ b/docs/glosario.rst @@ -8,7 +8,7 @@ Glosario Un administrador de documentos CFDI CFDI - Certificado de Firma Digital Integrada + Comprobante Fiscal Digital por Internet SAT Servicio de Administración Tributaria From 4b31ee0a768ed7fdd17ce12e9d102d9b5c248fa4 Mon Sep 17 00:00:00 2001 From: Mauricio Baeza Date: Sun, 29 Mar 2015 23:11:07 -0600 Subject: [PATCH 070/167] =?UTF-8?q?Se=20agrega=20el=20tema=20instalaci?= =?UTF-8?q?=C3=B3n=20al=20=C3=ADndice?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/index.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/index.rst b/docs/index.rst index cec7834..89c368d 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -12,6 +12,7 @@ Contents: :maxdepth: 2 intro + install uso devel reference From b4828575530fa4d8c1e33ba31deb3d6aa9cfe9f2 Mon Sep 17 00:00:00 2001 From: Mauricio Baeza Date: Sun, 29 Mar 2015 23:11:37 -0600 Subject: [PATCH 071/167] =?UTF-8?q?Se=20inicia=20el=20tema:=20instalaci?= =?UTF-8?q?=C3=B3n?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/install.rst | 66 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 docs/install.rst diff --git a/docs/install.rst b/docs/install.rst new file mode 100644 index 0000000..d4de5c1 --- /dev/null +++ b/docs/install.rst @@ -0,0 +1,66 @@ +=========== +Instalación +=========== + +Para instalar :term:`admin-cfdi` descarga la ultima versión de producción desde +`Github`_ e instala con el comando. + +:: + + sudo python setup.py install + +Si lo prefieres usa un entorno virtual. + +#. Para `LinuxMint`_ + + * Crea el entorno virtual + + :: + + pyvenv-3.4 test_admin --without-pip + + * Activalo + + :: + + cd test_admin/ + source bin/activate + + * Instala pip + + :: + + wget https://bootstrap.pypa.io/get-pip.py + python get-pip.py + + * Instala :term:`admin-cfdi` + + :: + + python setup.py install + +#. Para `ArchLinux`_ + + * Crea el entorno virtual + + :: + + pyvenv test_admin + + * Activalo + + :: + + cd test_admin/ + source bin/activate + + * Instala :term:`admin-cfdi` + + :: + + python setup.py install + + +.. _Github: https://github.com/LinuxCabal/admin-cfdi +.. _LinuxMint: http://linuxmint.com/ +.. _ArchLinux: https://www.archlinux.org/ From 2a75c82418349d711babf9e37d3cb9905711ed29 Mon Sep 17 00:00:00 2001 From: Mauricio Baeza Date: Sun, 29 Mar 2015 23:55:25 -0600 Subject: [PATCH 072/167] Se valida si hay soporte para LibreOffice --- admincfdi/pyutil.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/admincfdi/pyutil.py b/admincfdi/pyutil.py index 4c41dcf..9b3c7d7 100644 --- a/admincfdi/pyutil.py +++ b/admincfdi/pyutil.py @@ -49,15 +49,18 @@ WIN = 'win32' MAC = 'darwin' LINUX = 'linux' - +LIBO = True if sys.platform == WIN: from win32com.client import Dispatch elif sys.platform == LINUX: - import uno - from com.sun.star.beans import PropertyValue - from com.sun.star.beans.PropertyState import DIRECT_VALUE - from com.sun.star.awt import Size + try: + import uno + from com.sun.star.beans import PropertyValue + from com.sun.star.beans.PropertyState import DIRECT_VALUE + from com.sun.star.awt import Size + except ImportError: + LIBO = False class SAT(object): From 237c98ab8ddc19835271354c1dc7410fcb4d1f2d Mon Sep 17 00:00:00 2001 From: Mauricio Baeza Date: Mon, 30 Mar 2015 14:19:41 -0600 Subject: [PATCH 073/167] Se elimina template.py que usa ReportLab --- template.py | 176 ---------------------------------------------------- 1 file changed, 176 deletions(-) delete mode 100644 template.py diff --git a/template.py b/template.py deleted file mode 100644 index 7ba50ab..0000000 --- a/template.py +++ /dev/null @@ -1,176 +0,0 @@ -import datetime -import os -from reportlab.platypus import BaseDocTemplate, Frame, PageTemplate -from reportlab.lib import colors -from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle -from reportlab.lib.units import cm -from reportlab.lib.pagesizes import letter -from reportlab.lib.enums import TA_CENTER, TA_LEFT, TA_RIGHT -from reportlab.platypus import Paragraph, Table, TableStyle, Spacer -from reportlab.pdfgen import canvas - - -class NumberedCanvas(canvas.Canvas): - def __init__(self, *args, **kwargs): - #~ kwargs['bottomup'] = 0 - canvas.Canvas.__init__(self, *args, **kwargs) - self._saved_page_states = [] - - def showPage(self): - self._saved_page_states.append(dict(self.__dict__)) - self._startPage() - - def save(self): - """add page info to each page (page x of y)""" - page_count = len(self._saved_page_states) - for state in self._saved_page_states: - self.__dict__.update(state) - self.draw_page_number(page_count) - canvas.Canvas.showPage(self) - canvas.Canvas.save(self) - - def draw_page_number(self, page_count): - self.setFont("Helvetica", 8) - self.drawRightString(20.59 * cm, 1 * cm, - 'Página {} de {}'.format(self._pageNumber, page_count)) - - -class ReportTemplate(BaseDocTemplate): - """Override the BaseDocTemplate class to do custom handle_XXX actions""" - - def __init__(self, *args, **kwargs): - # letter 21.6 x 27.9 - kwargs['pagesize'] = letter - kwargs['rightMargin'] = 1 * cm - kwargs['leftMargin'] = 1 * cm - kwargs['topMargin'] = 4 * cm - kwargs['bottomMargin'] = 2 * cm - BaseDocTemplate.__init__(self, *args, **kwargs) - self.styles = getSampleStyleSheet() - self.header = {} - self.data = [] - - def afterPage(self): - """Called after each page has been processed""" - self.canv.saveState() - date = datetime.datetime.today().strftime('%A, %d de %B del %Y') - self.canv.setStrokeColorRGB(0, 0, 0.5) - self.canv.setFont("Helvetica", 8) - self.canv.drawRightString(20.59 * cm, 26.9 * cm, date) - self.canv.line(1 * cm, 26.4 * cm, 20.6 * cm, 26.4 * cm) - - path_cur = os.path.dirname(os.path.realpath(__file__)) - path_img = os.path.join(path_cur, 'logo.png') - try: - self.canv.drawImage(path_img, 1.5 * cm, 24.2 * cm, 2.5 * cm, 2 * cm) - except: - pass - - self.canv.roundRect( - 5 * cm, 25.4 * cm, 15.5 * cm, 0.6 * cm, 0.15 * cm, - stroke=True, fill=False) - self.canv.setFont('Helvetica-BoldOblique', 10) - self.canv.drawCentredString(12.75 * cm, 25.6 * cm, self.header['emisor']) - - self.canv.roundRect( - 5 * cm, 24.4 * cm, 15.5 * cm, 0.6 * cm, 0.15 * cm, - stroke=True, fill=False) - self.canv.setFont('Helvetica-BoldOblique', 9) - self.canv.drawCentredString(12.75 * cm, 24.6 * cm, self.header['title']) - - self.canv.line(1 * cm, 1.5 * cm, 20.6 * cm, 1.5 * cm) - self.canv.restoreState() - return - - def set_data(self, data): - self.header['emisor'] = data['emisor'] - self.header['title'] = data['title'] - cols = len(data['rows'][0]) - widths = [] - for w in data['widths']: - widths.append(float(w) * cm) - t_styles = [ - ('GRID', (0, 0), (-1, -1), 0.25, colors.darkblue), - ('FONTSIZE', (0, 0), (-1, 0), 9), - ('BOX', (0, 0), (-1, 0), 1, colors.darkblue), - ('TEXTCOLOR', (0, 0), (-1, 0), colors.darkblue), - ('FONTSIZE', (0, 1), (-1, -1), 8), - ('ALIGN', (0, 0), (-1, 0), 'CENTER'), - ('ALIGN', (0, 0), (0, -1), 'RIGHT'), - ] - if cols == 6: - t_styles += [ - ('ALIGN', (1, 1), (1, -1), 'CENTER'), - ('ALIGN', (3, 1), (3, -1), 'CENTER'), - ('ALIGN', (4, 1), (4, -1), 'RIGHT'), - ] - elif cols == 3: - t_styles += [ - ('ALIGN', (-1, 0), (-1, -1), 'RIGHT'), - ('ALIGN', (-2, 0), (-2, -1), 'RIGHT'), - ('ALIGN', (0, 0), (-1, 0), 'CENTER'), - ] - elif cols == 2: - t_styles += [ - ('ALIGN', (-1, 0), (-1, -1), 'RIGHT'), - ('ALIGN', (0, 0), (-1, 0), 'CENTER'), - ] - rows = [] - for i, r in enumerate(data['rows']): - n = i + 1 - rows.append(('{}.-'.format(n),) + r) - if cols == 6: - if r[4] == 'Cancelado': - t_styles += [ - ('GRID', (0, n), (-1, n), 0.25, colors.red), - ('TEXTCOLOR', (0, n), (-1, n), colors.red), - ] - rows.insert(0, data['titles']) - t = Table(rows, colWidths=widths, repeatRows=1) - t.setStyle(TableStyle(t_styles)) - - text = 'Total este reporte = $ {}'.format(data['total']) - ps = ParagraphStyle( - name='Total', - fontSize=12, - fontName='Helvetica-BoldOblique', - textColor=colors.darkblue, - spaceBefore=0.5 * cm, - spaceAfter=0.5 * cm) - p1 = Paragraph(text, ps) - text = 'Nota: esta suma no incluye documentos cancelados' - ps = ParagraphStyle( - name='Note', - fontSize=7, - fontName='Helvetica-BoldOblique') - p2 = Paragraph(text, ps) - self.data = [t, p1, p2] - return - - def make_pdf(self): - frame = Frame( - self.leftMargin, - self.bottomMargin, - self.width, - self.height, - id='normal') - template = PageTemplate(id='report', frames=frame) - self.addPageTemplates([template]) - self.build(self.data, canvasmaker=NumberedCanvas) - return - - -if __name__ == "__main__": - doc = ReportTemplate('filename.pdf') - styles = getSampleStyleSheet() - styleN = styles['Normal'] - styleH = styles['Heading1'] - data = [] - for i in range(100): - data.append(Paragraph("This is line %d." % i, styleN)) - frame = Frame(doc.leftMargin, doc.bottomMargin, doc.width, doc.height, id='normal') - template = PageTemplate(id='report', frames=frame) - doc.addPageTemplates([template]) - # Build your doc with your elements and go grab a beer - doc.build(data, canvasmaker=NumberedCanvas) - From e8ad74952bef199fe015f8e052726ee9d7f9898e Mon Sep 17 00:00:00 2001 From: Mauricio Baeza Date: Mon, 30 Mar 2015 14:35:06 -0600 Subject: [PATCH 074/167] Deshabilitar plantilla ODS en interfaz de usuario, si no hay soporte para LibreOffice --- admin-cfdi | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/admin-cfdi b/admin-cfdi index 43da7eb..2aa4a7a 100644 --- a/admin-cfdi +++ b/admin-cfdi @@ -16,6 +16,7 @@ import pygubu from selenium import webdriver from admincfdi.pyutil import Util, Mail, LibO, CFDIPDF, DescargaSAT from admincfdi.values import Global +from admincfdi.pyutil import LIBO class Application(pygubu.TkApplication): @@ -122,6 +123,9 @@ class Application(pygubu.TkApplication): self._set('mail_port', 993) self._get_object('check_ssl').invoke() self._focus_set('text_rfc') + + if not LIBO: + self._config('radio_ods', {'state': 'disabled'}) return def _center_window(self, root): From 99bbe0cd40ea4aac1509b9715cd753a8d011e5cc Mon Sep 17 00:00:00 2001 From: Mauricio Baeza Date: Mon, 30 Mar 2015 14:43:49 -0600 Subject: [PATCH 075/167] =?UTF-8?q?Se=20cambia=20en=20la=20interfaz=20y=20?= =?UTF-8?q?en=20c=C3=B3digo=20JSON=20por=20CSV?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- admin-cfdi | 38 +++++++++++++++++++------------------- admincfdi/ui/mainwindow.ui | 12 ++++++------ 2 files changed, 25 insertions(+), 25 deletions(-) diff --git a/admin-cfdi b/admin-cfdi index 2aa4a7a..bdff83d 100644 --- a/admin-cfdi +++ b/admin-cfdi @@ -262,10 +262,10 @@ class Application(pygubu.TkApplication): radio = 'radio_ods' button = 'button_select_template_ods' self._config(button, {'state': 'normal'}) - self._config('button_select_template_json', {'state': 'normal'}) + self._config('button_select_template_csv', {'state': 'normal'}) if opt == 1: - radio = 'radio_json' - button = 'button_select_template_json' + radio = 'radio_csv' + button = 'button_select_template_csv' self._config(radio, {'bg': self.g.COLORS['DEFAULT']}) self._config(button, {'state': 'disabled'}) return @@ -892,13 +892,13 @@ class Application(pygubu.TkApplication): file_name = self.util.get_file(self.parent, ext=self.g.EXT_ODS) if file_name: self._set('template_ods', file_name) - self._set('template_json') + self._set('template_csv') return - def button_select_template_json_click(self): - file_name = self.util.get_file(self.parent, ext=self.g.EXT_JSON) + def button_select_template_csv_click(self): + file_name = self.util.get_file(self.parent, ext=self.g.EXT_CSV) if file_name: - self._set('template_json', file_name) + self._set('template_csv', file_name) self._set('template_ods') return @@ -915,7 +915,7 @@ class Application(pygubu.TkApplication): 'pdf_source', 'pdf_target', 'template_ods', - 'template_json' + 'template_csv' ) for v in var: self._set(v) @@ -973,13 +973,13 @@ class Application(pygubu.TkApplication): self.util.msgbox(msg) return False, data template_ods = self.util.path_config(template_ods) - template_json = self._get('template_json') - if template_json: - if not self.util.validate_dir(template_json): - msg = 'No se encontró la plantilla JSON' + template_csv = self._get('template_csv') + if template_csv: + if not self.util.validate_dir(template_csv): + msg = 'No se encontró la plantilla CSV' self.util.msgbox(msg) return False, data - template_json = self.util.path_config(template_json) + template_csv = self.util.path_config(template_csv) template_type = self._get('template_type') if template_type == 1: @@ -988,8 +988,8 @@ class Application(pygubu.TkApplication): self.util.msgbox(msg) return False, data elif template_type == 2: - if not template_json: - msg = 'Selecciona una plantilla JSON' + if not template_csv: + msg = 'Selecciona una plantilla CSV' self.util.msgbox(msg) return False, data @@ -997,7 +997,7 @@ class Application(pygubu.TkApplication): data['pdf_source'] = self.util.path_config(pdf_source) data['pdf_target'] = self.util.path_config(pdf_target) data['template_ods'] = template_ods - data['template_json'] = template_json + data['template_csv'] = template_csv #~ data['pdf_print'] = self._get('pdf_print') return True, data @@ -1010,7 +1010,7 @@ class Application(pygubu.TkApplication): 'pdf_source', 'pdf_target', 'template_ods', - 'template_json', + 'template_csv', ) for v in var: self._set(v, self.users_pdf[sel][v]) @@ -1069,8 +1069,8 @@ class Application(pygubu.TkApplication): source = data['pdf_source'] target = data['pdf_target'] template = data['template_ods'] - if data['template_json']: - template = data['template_json'] + if data['template_csv']: + template = data['template_csv'] info = self.util.get_path_info(path) path_pdf = self.util.join(target, info[0][len(source)+1:]) self.util.makedirs(path_pdf) diff --git a/admincfdi/ui/mainwindow.ui b/admincfdi/ui/mainwindow.ui index 3f7521c..4953811 100644 --- a/admincfdi/ui/mainwindow.ui +++ b/admincfdi/ui/mainwindow.ui @@ -1569,9 +1569,9 @@ - + {Sans} 10 {} - JSON + CSV 2 int:template_type @@ -1614,11 +1614,11 @@ - + {Sans} 10 {} #ff5500 readonly - string:template_json + string:template_csv 36 @@ -1668,8 +1668,8 @@ - - button_select_template_json_click + + button_select_template_csv_click csv.gif 1 From 2b708798f372e2f2e1398e3dbfc64003fdb1888b Mon Sep 17 00:00:00 2001 From: Patricio Paez Date: Sat, 4 Apr 2015 10:27:50 -0600 Subject: [PATCH 076/167] Borrar admincfdi.log --- admincfdi.log | 0 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 admincfdi.log diff --git a/admincfdi.log b/admincfdi.log deleted file mode 100644 index e69de29..0000000 From 7e7d6edb61fa9207f5dcb568e4bcd3fcdf619ce5 Mon Sep 17 00:00:00 2001 From: Patricio Paez Date: Sun, 8 Mar 2015 08:27:27 -0600 Subject: [PATCH 077/167] =?UTF-8?q?Agregar=20par=C3=A1metros=20individuale?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - En __init__(), _download_sat() y _download_sat_month() - Se pasan solamente los parámetros requeridos - Remplazar todos los usos de data en _download_sat() y _download_sat_month() con el parámetro correspondiente --- admincfdi/pyutil.py | 90 +++++++++++++++++++++++++-------------------- 1 file changed, 50 insertions(+), 40 deletions(-) diff --git a/admincfdi/pyutil.py b/admincfdi/pyutil.py index 5aa68bd..0cf6cb8 100644 --- a/admincfdi/pyutil.py +++ b/admincfdi/pyutil.py @@ -1665,20 +1665,36 @@ def _format_date(self, date_string): class DescargaSAT(object): - def __init__(self, data, status_callback=print, - download_callback=print): + def __init__(self, facturas_emitidas=False, + type_search=0, + rfc='', ciec='', carpeta_destino='', + uuid='', rfc_emisor='', + año=None, mes=None, día=None, + mes_completo_por_día=False, + status_callback=print, + download_callback=print): self.g = Global() self.util = Util() self.status = status_callback self.progress = download_callback - self._download_sat(data) - - def _download_sat(self, data): + self._download_sat(facturas_emitidas, + type_search, + rfc, ciec, carpeta_destino, + uuid, rfc_emisor, + año, mes, día, + mes_completo_por_día) + + def _download_sat(self, facturas_emitidas, + type_search, + rfc, ciec, carpeta_destino, + uuid, rfc_emisor, + año, mes, día, + mes_completo_por_día): 'Descarga CFDIs del SAT a una carpeta local' self.status('Abriendo Firefox...') page_query = self.g.SAT['page_receptor'] - if data['type_invoice'] == 1: + if facturas_emitidas == 1: page_query = self.g.SAT['page_emisor'] # To prevent download dialog profile = webdriver.FirefoxProfile() @@ -1692,7 +1708,7 @@ def _download_sat(self, data): 'browser.helperApps.neverAsk.saveToDisk', 'text/xml, application/octet-stream, application/xml') profile.set_preference( - 'browser.download.dir', data['user_sat']['target_sat']) + 'browser.download.dir', carpeta_destino) # mrE - desactivar telemetry profile.set_preference( 'toolkit.telemetry.prompted', 2) @@ -1722,34 +1738,34 @@ def _download_sat(self, data): self.status('Conectando...') browser.get(self.g.SAT['page_init']) txt = browser.find_element_by_name(self.g.SAT['user']) - txt.send_keys(data['user_sat']['user_sat']) + txt.send_keys(rfc) txt = browser.find_element_by_name(self.g.SAT['password']) - txt.send_keys(data['user_sat']['password']) + txt.send_keys(ciec) txt.submit() self.util.sleep(3) self.status('Conectado...') browser.get(page_query) self.util.sleep(3) self.status('Buscando...') - if data['type_search'] == 1: + if type_search == 1: txt = browser.find_element_by_id(self.g.SAT['uuid']) txt.click() - txt.send_keys(data['search_uuid']) + txt.send_keys(uuid) else: # Descargar por fecha opt = browser.find_element_by_id(self.g.SAT['date']) opt.click() self.util.sleep() - if data['search_rfc']: - if data['type_search'] == 1: + if rfc_emisor: + if type_search == 1: txt = browser.find_element_by_id(self.g.SAT['receptor']) else: txt = browser.find_element_by_id(self.g.SAT['emisor']) - txt.send_keys(data['search_rfc']) + txt.send_keys(rfc_emisor) # Emitidas - if data['type_invoice'] == 1: - year = int(data['search_year']) - month = int(data['search_month']) + if facturas_emitidas == 1: + year = int(año) + month = int(mes) dates = self.util.get_dates(year, month) txt = browser.find_element_by_id(self.g.SAT['date_from']) arg = "document.getElementsByName('{}')[0]." \ @@ -1773,8 +1789,7 @@ def _download_sat(self, data): 'sbToggle_{}'.format(combo.get_attribute('sb'))) combo.click() self.util.sleep(2) - link = browser.find_element_by_link_text( - data['search_year']) + link = browser.find_element_by_link_text(año) link.click() self.util.sleep(2) combo = browser.find_element_by_id(self.g.SAT['month']) @@ -1782,20 +1797,18 @@ def _download_sat(self, data): 'sbToggle_{}'.format(combo.get_attribute('sb'))) combo.click() self.util.sleep(2) - link = browser.find_element_by_link_text( - data['search_month']) + link = browser.find_element_by_link_text(mes) link.click() self.util.sleep(2) - if data['search_day'] != '00': + if día != '00': combo = browser.find_element_by_id(self.g.SAT['day']) sb = combo.get_attribute('sb') combo = browser.find_element_by_id( 'sbToggle_{}'.format(sb)) combo.click() self.util.sleep() - if data['search_month'] == data['search_day']: - links = browser.find_elements_by_link_text( - data['search_day']) + if mes == día: + links = browser.find_elements_by_link_text(día) for l in links: p = l.find_element_by_xpath( '..').find_element_by_xpath('..') @@ -1804,27 +1817,25 @@ def _download_sat(self, data): link = l break else: - link = browser.find_element_by_link_text( - data['search_day']) + link = browser.find_element_by_link_text(día) link.click() self.util.sleep() browser.find_element_by_id(self.g.SAT['submit']).click() sec = 3 - if data['type_invoice'] != 1 and data['search_day'] == '00': + if facturas_emitidas != 1 and día == '00': sec = 15 self.util.sleep(sec) # Bug del SAT - if data['type_invoice'] != 1 and data['search_day'] != '00': + if facturas_emitidas != 1 and día != '00': combo = browser.find_element_by_id(self.g.SAT['day']) sb = combo.get_attribute('sb') combo = browser.find_element_by_id( 'sbToggle_{}'.format(sb)) combo.click() self.util.sleep(2) - if data['search_month'] == data['search_day']: - links = browser.find_elements_by_link_text( - data['search_day']) + if mes == día: + links = browser.find_elements_by_link_text(día) for l in links: p = l.find_element_by_xpath( '..').find_element_by_xpath('..') @@ -1833,14 +1844,13 @@ def _download_sat(self, data): link = l break else: - link = browser.find_element_by_link_text( - data['search_day']) + link = browser.find_element_by_link_text(día) link.click() self.util.sleep(2) browser.find_element_by_id(self.g.SAT['submit']).click() self.util.sleep(sec) - elif data['type_invoice'] == 2 and data['sat_month']: - return self._download_sat_month(data, browser) + elif facturas_emitidas == 2 and mes_completo_por_día: + return self._download_sat_month(año, mes, browser) try: found = True @@ -1882,13 +1892,13 @@ def _download_sat(self, data): self.status('Desconectado...') return - def _download_sat_month(self, data, browser): + def _download_sat_month(self, año, mes, browser): '''Descarga CFDIs del SAT a una carpeta local Todos los CFDIs del mes selecionado''' - year = int(data['search_year']) - month = int(data['search_month']) + year = int(año) + month = int(mes) days_month = self.util.get_days(year, month) + 1 days = ['%02d' % x for x in range(1, days_month)] for d in days: @@ -1897,7 +1907,7 @@ def _download_sat_month(self, data, browser): combo = browser.find_element_by_id('sbToggle_{}'.format(sb)) combo.click() self.util.sleep(2) - if data['search_month'] == d: + if mes == d: links = browser.find_elements_by_link_text(d) for l in links: p = l.find_element_by_xpath( From ead2b52cd6d7a9fc782bfc475112ca1737d17b4d Mon Sep 17 00:00:00 2001 From: Patricio Paez Date: Sun, 8 Mar 2015 08:38:27 -0600 Subject: [PATCH 078/167] =?UTF-8?q?Usar=20los=20par=C3=A1metros?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - admincfdi.py: _validate_download_sat() usa los nombres de los parámetros, _button_download_sat_click() llama con **data - descarga.py: data usa los nombres de los parámetros y llama con **data --- admin-cfdi | 24 ++++++++++++++---------- descarga-cfdi | 22 +++++++++++----------- 2 files changed, 25 insertions(+), 21 deletions(-) diff --git a/admin-cfdi b/admin-cfdi index 3b341e3..b2f3474 100644 --- a/admin-cfdi +++ b/admin-cfdi @@ -381,8 +381,9 @@ class Application(pygubu.TkApplication): ok, data = self._validate_download_sat() if not ok: return - DescargaSAT(data, status_callback=self.msg_user, - download_callback=self.progress) + DescargaSAT(status_callback=self.msg_user, + download_callback=self.progress, + **data) return def _validate_download_sat(self): @@ -472,16 +473,19 @@ class Application(pygubu.TkApplication): search_day = '00' else: search_day = self._get('search_day') + user = self.users_sat[current_user] data = { - 'user_sat': self.users_sat[current_user], - 'type_invoice': self._get('type_invoice'), + 'facturas_emitidas': self._get('type_invoice'), 'type_search': opt, - 'search_uuid': uuid, - 'search_rfc': rfc, - 'search_year': self._get('search_year'), - 'search_month': self._get('search_month'), - 'search_day': search_day, - 'sat_month': sat_month, + 'rfc': user['user_sat'], + 'ciec': user['password'], + 'carpeta_destino': user['target_sat'], + 'uuid': uuid, + 'rfc_emisor': rfc, + 'año': self._get('search_year'), + 'mes': self._get('search_month'), + 'día': search_day, + 'mes_completo_por_día': sat_month, } return True, data diff --git a/descarga-cfdi b/descarga-cfdi index 6b77387..d4c500c 100755 --- a/descarga-cfdi +++ b/descarga-cfdi @@ -88,19 +88,19 @@ def main(): pwd = getpass.getpass('CIEC: ') else: rfc, pwd = open(args.archivo_de_credenciales).readline()[:-1].split() - data = {'type_invoice': args.facturas_emitidas, + data = {'facturas_emitidas': args.facturas_emitidas, 'type_search': 1 * (args.uuid != ''), - 'user_sat': {'target_sat': args.carpeta_destino, - 'user_sat': rfc, - 'password': pwd}, - 'search_uuid': args.uuid, - 'search_rfc': args.rfc_emisor, - 'search_year': args.año, - 'search_month': args.mes, - 'search_day': args.día, - 'sat_month': args.mes_completo_por_día + 'rfc': rfc, + 'ciec': pwd, + 'carpeta_destino': args.carpeta_destino, + 'uuid': args.uuid, + 'rfc_emisor': args.rfc_emisor, + 'año': args.año, + 'mes': args.mes, + 'día': args.día, + 'mes_completo_por_día': args.mes_completo_por_día } - descarga = DescargaSAT(data) + descarga = DescargaSAT(**data) if __name__ == '__main__': From 35517632a859367490c574f74e3ed943d1782df7 Mon Sep 17 00:00:00 2001 From: Patricio Paez Date: Sun, 8 Mar 2015 09:27:03 -0600 Subject: [PATCH 079/167] Crear prueba funcional de DescargaSAT MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Cuatro casos de prueba - Utiliza functional_DescargaSAT.conf para adecuar los parámetros y resultados esperados de cada prueba al usuario con el que se realiza --- functional_DescargaSAT.conf.sample | 21 ++++++ functional_DescargaSAT.py | 104 +++++++++++++++++++++++++++++ 2 files changed, 125 insertions(+) create mode 100644 functional_DescargaSAT.conf.sample create mode 100755 functional_DescargaSAT.py diff --git a/functional_DescargaSAT.conf.sample b/functional_DescargaSAT.conf.sample new file mode 100644 index 0000000..83f31cc --- /dev/null +++ b/functional_DescargaSAT.conf.sample @@ -0,0 +1,21 @@ +[uuid] +uuid=XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX +expected=1 + +[rfc_emisor] +rfc_emisor=XXXXXXXXXXXX +año=2014 +mes=09 +día=26 +expected=1 + +[año_mes_día] +año=2014 +mes=09 +día=26 +expected=1 + +[mes_completo_por_día] +año=2014 +mes=09 +expected=5 diff --git a/functional_DescargaSAT.py b/functional_DescargaSAT.py new file mode 100755 index 0000000..dbff58a --- /dev/null +++ b/functional_DescargaSAT.py @@ -0,0 +1,104 @@ +import unittest + + +class DescargaSAT(unittest.TestCase): + + def setUp(self): + import configparser + + self.config = configparser.ConfigParser() + self.config.read('functional_DescargaSAT.conf' ) + + def test_uuid(self): + import os + import tempfile + from pyutil import DescargaSAT + + def no_op(*args): + pass + + rfc, ciec = open('credenciales.conf').readline()[:-1].split() + seccion = self.config['uuid'] + uuid = seccion['uuid'] + expected = int(seccion['expected']) + with tempfile.TemporaryDirectory() as tempdir: + destino = os.path.join(tempdir, 'cfdi-descarga') + descarga = DescargaSAT(uuid=uuid, + type_search=1, + día='00', + rfc=rfc, ciec=ciec, + carpeta_destino=destino, + status_callback=no_op, download_callback=no_op) + self.assertEqual(expected, len(os.listdir(destino))) + + def test_rfc(self): + import os + import tempfile + from pyutil import DescargaSAT + + def no_op(*args): + pass + + rfc, ciec = open('credenciales.conf').readline()[:-1].split() + seccion = self.config['rfc_emisor'] + rfc_emisor = seccion['rfc_emisor'] + año = seccion['año'] + mes = seccion['mes'] + día = seccion['día'] + expected = int(seccion['expected']) + with tempfile.TemporaryDirectory() as tempdir: + destino = os.path.join(tempdir, 'cfdi-descarga') + descarga = DescargaSAT(año=año, mes=mes, día=día, + rfc_emisor=rfc_emisor, + rfc=rfc, ciec=ciec, + carpeta_destino=destino, + status_callback=no_op, download_callback=no_op) + self.assertEqual(expected, len(os.listdir(destino))) + + def test_año_mes_día(self): + import os + import tempfile + from pyutil import DescargaSAT + + def no_op(*args): + pass + + rfc, ciec = open('credenciales.conf').readline()[:-1].split() + seccion = self.config['año_mes_día'] + año = seccion['año'] + mes = seccion['mes'] + día = seccion['día'] + expected = int(seccion['expected']) + with tempfile.TemporaryDirectory() as tempdir: + destino = os.path.join(tempdir, 'cfdi-descarga') + descarga = DescargaSAT(año=año, mes=mes, día=día, + rfc=rfc, ciec=ciec, + carpeta_destino=destino, + status_callback=no_op, download_callback=no_op) + self.assertEqual(expected, len(os.listdir(destino))) + + def test_mes_completo(self): + import os + import tempfile + from pyutil import DescargaSAT + + def no_op(*args): + pass + + rfc, ciec = open('credenciales.conf').readline()[:-1].split() + seccion = self.config['mes_completo_por_día'] + año = seccion['año'] + mes = seccion['mes'] + expected = int(seccion['expected']) + with tempfile.TemporaryDirectory() as tempdir: + destino = os.path.join(tempdir, 'cfdi-descarga') + descarga = DescargaSAT(año=año, mes=mes, día='00', + mes_completo_por_día=True, + rfc=rfc, ciec=ciec, + carpeta_destino=destino, + status_callback=no_op, download_callback=no_op) + self.assertEqual(expected, len(os.listdir(destino))) + + +if __name__ == '__main__': + unittest.main() From a45c7e36d4bebfec96cb2d6598762f974fdd4b7d Mon Sep 17 00:00:00 2001 From: Patricio Paez Date: Sat, 4 Apr 2015 16:20:50 -0600 Subject: [PATCH 080/167] Referir al paquete admincfdi --- functional_DescargaSAT.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/functional_DescargaSAT.py b/functional_DescargaSAT.py index dbff58a..3e82491 100755 --- a/functional_DescargaSAT.py +++ b/functional_DescargaSAT.py @@ -12,7 +12,7 @@ def setUp(self): def test_uuid(self): import os import tempfile - from pyutil import DescargaSAT + from admincfdi.pyutil import DescargaSAT def no_op(*args): pass @@ -34,7 +34,7 @@ def no_op(*args): def test_rfc(self): import os import tempfile - from pyutil import DescargaSAT + from admincfdi.pyutil import DescargaSAT def no_op(*args): pass @@ -58,7 +58,7 @@ def no_op(*args): def test_año_mes_día(self): import os import tempfile - from pyutil import DescargaSAT + from admincfdi.pyutil import DescargaSAT def no_op(*args): pass @@ -80,7 +80,7 @@ def no_op(*args): def test_mes_completo(self): import os import tempfile - from pyutil import DescargaSAT + from admincfdi.pyutil import DescargaSAT def no_op(*args): pass From 397e744f2a236f015d9b6f0528519545e29764a7 Mon Sep 17 00:00:00 2001 From: Patricio Paez Date: Sat, 4 Apr 2015 16:35:33 -0600 Subject: [PATCH 081/167] Mover la lectura de las credenciales a setUp() --- functional_DescargaSAT.py | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/functional_DescargaSAT.py b/functional_DescargaSAT.py index 3e82491..48b33d1 100755 --- a/functional_DescargaSAT.py +++ b/functional_DescargaSAT.py @@ -9,6 +9,8 @@ def setUp(self): self.config = configparser.ConfigParser() self.config.read('functional_DescargaSAT.conf' ) + self.rfc, self.ciec = open('credenciales.conf').readline()[:-1].split() + def test_uuid(self): import os import tempfile @@ -17,7 +19,6 @@ def test_uuid(self): def no_op(*args): pass - rfc, ciec = open('credenciales.conf').readline()[:-1].split() seccion = self.config['uuid'] uuid = seccion['uuid'] expected = int(seccion['expected']) @@ -26,7 +27,7 @@ def no_op(*args): descarga = DescargaSAT(uuid=uuid, type_search=1, día='00', - rfc=rfc, ciec=ciec, + rfc=self.rfc, ciec=self.ciec, carpeta_destino=destino, status_callback=no_op, download_callback=no_op) self.assertEqual(expected, len(os.listdir(destino))) @@ -39,7 +40,6 @@ def test_rfc(self): def no_op(*args): pass - rfc, ciec = open('credenciales.conf').readline()[:-1].split() seccion = self.config['rfc_emisor'] rfc_emisor = seccion['rfc_emisor'] año = seccion['año'] @@ -50,7 +50,7 @@ def no_op(*args): destino = os.path.join(tempdir, 'cfdi-descarga') descarga = DescargaSAT(año=año, mes=mes, día=día, rfc_emisor=rfc_emisor, - rfc=rfc, ciec=ciec, + rfc=self.rfc, ciec=self.ciec, carpeta_destino=destino, status_callback=no_op, download_callback=no_op) self.assertEqual(expected, len(os.listdir(destino))) @@ -63,7 +63,6 @@ def test_año_mes_día(self): def no_op(*args): pass - rfc, ciec = open('credenciales.conf').readline()[:-1].split() seccion = self.config['año_mes_día'] año = seccion['año'] mes = seccion['mes'] @@ -72,7 +71,7 @@ def no_op(*args): with tempfile.TemporaryDirectory() as tempdir: destino = os.path.join(tempdir, 'cfdi-descarga') descarga = DescargaSAT(año=año, mes=mes, día=día, - rfc=rfc, ciec=ciec, + rfc=self.rfc, ciec=self.ciec, carpeta_destino=destino, status_callback=no_op, download_callback=no_op) self.assertEqual(expected, len(os.listdir(destino))) @@ -85,7 +84,6 @@ def test_mes_completo(self): def no_op(*args): pass - rfc, ciec = open('credenciales.conf').readline()[:-1].split() seccion = self.config['mes_completo_por_día'] año = seccion['año'] mes = seccion['mes'] @@ -94,7 +92,7 @@ def no_op(*args): destino = os.path.join(tempdir, 'cfdi-descarga') descarga = DescargaSAT(año=año, mes=mes, día='00', mes_completo_por_día=True, - rfc=rfc, ciec=ciec, + rfc=self.rfc, ciec=self.ciec, carpeta_destino=destino, status_callback=no_op, download_callback=no_op) self.assertEqual(expected, len(os.listdir(destino))) From b778150429e0c9f3fa8d2f1a1f48c4d05d06aaff Mon Sep 17 00:00:00 2001 From: Patricio Paez Date: Sat, 4 Apr 2015 16:59:58 -0600 Subject: [PATCH 082/167] =?UTF-8?q?Usar=20directamente=20los=20par=C3=A1me?= =?UTF-8?q?tros?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- descarga-cfdi | 24 +++++++++++------------- 1 file changed, 11 insertions(+), 13 deletions(-) diff --git a/descarga-cfdi b/descarga-cfdi index d4c500c..245776d 100755 --- a/descarga-cfdi +++ b/descarga-cfdi @@ -88,19 +88,17 @@ def main(): pwd = getpass.getpass('CIEC: ') else: rfc, pwd = open(args.archivo_de_credenciales).readline()[:-1].split() - data = {'facturas_emitidas': args.facturas_emitidas, - 'type_search': 1 * (args.uuid != ''), - 'rfc': rfc, - 'ciec': pwd, - 'carpeta_destino': args.carpeta_destino, - 'uuid': args.uuid, - 'rfc_emisor': args.rfc_emisor, - 'año': args.año, - 'mes': args.mes, - 'día': args.día, - 'mes_completo_por_día': args.mes_completo_por_día - } - descarga = DescargaSAT(**data) + descarga = DescargaSAT(facturas_emitidas= args.facturas_emitidas, + type_search=1 * (args.uuid != ''), + rfc=rfc, + ciec=pwd, + carpeta_destino=args.carpeta_destino, + uuid=args.uuid, + rfc_emisor=args.rfc_emisor, + año=args.año, + mes=args.mes, + día=args.día, + mes_completo_por_día=args.mes_completo_por_día) if __name__ == '__main__': From a148e1f63dcef04af5a28c578e80a763c0780194 Mon Sep 17 00:00:00 2001 From: Patricio Paez Date: Sat, 4 Apr 2015 23:02:07 -0600 Subject: [PATCH 083/167] Separar el llamado de _download_sat() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Ahora es necesario instanciar DescargaSAT y después invocar _download_sat() - Al instanciar se pueden proporcionar solo los dos callbacks opcionales. - _download_sat() recibe los demás parámetros opcionales. - Se validan estos cambios con las pruebas funcionales. - El uso de _download_sat() se actualizó en las pruebas funcionales. --- admincfdi/pyutil.py | 28 ++++++++-------------------- functional_DescargaSAT.py | 28 ++++++++++++++++------------ 2 files changed, 24 insertions(+), 32 deletions(-) diff --git a/admincfdi/pyutil.py b/admincfdi/pyutil.py index 0cf6cb8..73613d9 100644 --- a/admincfdi/pyutil.py +++ b/admincfdi/pyutil.py @@ -1665,31 +1665,19 @@ def _format_date(self, date_string): class DescargaSAT(object): - def __init__(self, facturas_emitidas=False, - type_search=0, - rfc='', ciec='', carpeta_destino='', - uuid='', rfc_emisor='', - año=None, mes=None, día=None, - mes_completo_por_día=False, - status_callback=print, + def __init__(self, status_callback=print, download_callback=print): self.g = Global() self.util = Util() self.status = status_callback self.progress = download_callback - self._download_sat(facturas_emitidas, - type_search, - rfc, ciec, carpeta_destino, - uuid, rfc_emisor, - año, mes, día, - mes_completo_por_día) - - def _download_sat(self, facturas_emitidas, - type_search, - rfc, ciec, carpeta_destino, - uuid, rfc_emisor, - año, mes, día, - mes_completo_por_día): + + def _download_sat(self, facturas_emitidas=False, + type_search=0, + rfc='', ciec='', carpeta_destino='', + uuid='', rfc_emisor='', + año=None, mes=None, día=None, + mes_completo_por_día=False): 'Descarga CFDIs del SAT a una carpeta local' self.status('Abriendo Firefox...') diff --git a/functional_DescargaSAT.py b/functional_DescargaSAT.py index 48b33d1..03c65ca 100755 --- a/functional_DescargaSAT.py +++ b/functional_DescargaSAT.py @@ -24,12 +24,13 @@ def no_op(*args): expected = int(seccion['expected']) with tempfile.TemporaryDirectory() as tempdir: destino = os.path.join(tempdir, 'cfdi-descarga') - descarga = DescargaSAT(uuid=uuid, + descarga = DescargaSAT(status_callback=no_op, + download_callback=no_op) + descarga._download_sat(uuid=uuid, type_search=1, día='00', rfc=self.rfc, ciec=self.ciec, - carpeta_destino=destino, - status_callback=no_op, download_callback=no_op) + carpeta_destino=destino) self.assertEqual(expected, len(os.listdir(destino))) def test_rfc(self): @@ -48,11 +49,12 @@ def no_op(*args): expected = int(seccion['expected']) with tempfile.TemporaryDirectory() as tempdir: destino = os.path.join(tempdir, 'cfdi-descarga') - descarga = DescargaSAT(año=año, mes=mes, día=día, + descarga = DescargaSAT(status_callback=no_op, + download_callback=no_op) + descarga._download_sat(año=año, mes=mes, día=día, rfc_emisor=rfc_emisor, rfc=self.rfc, ciec=self.ciec, - carpeta_destino=destino, - status_callback=no_op, download_callback=no_op) + carpeta_destino=destino) self.assertEqual(expected, len(os.listdir(destino))) def test_año_mes_día(self): @@ -70,10 +72,11 @@ def no_op(*args): expected = int(seccion['expected']) with tempfile.TemporaryDirectory() as tempdir: destino = os.path.join(tempdir, 'cfdi-descarga') - descarga = DescargaSAT(año=año, mes=mes, día=día, + descarga = DescargaSAT(status_callback=no_op, + download_callback=no_op) + descarga._download_sat(año=año, mes=mes, día=día, rfc=self.rfc, ciec=self.ciec, - carpeta_destino=destino, - status_callback=no_op, download_callback=no_op) + carpeta_destino=destino) self.assertEqual(expected, len(os.listdir(destino))) def test_mes_completo(self): @@ -90,11 +93,12 @@ def no_op(*args): expected = int(seccion['expected']) with tempfile.TemporaryDirectory() as tempdir: destino = os.path.join(tempdir, 'cfdi-descarga') - descarga = DescargaSAT(año=año, mes=mes, día='00', + descarga = DescargaSAT(status_callback=no_op, + download_callback=no_op) + descarga._download_sat(año=año, mes=mes, día='00', mes_completo_por_día=True, rfc=self.rfc, ciec=self.ciec, - carpeta_destino=destino, - status_callback=no_op, download_callback=no_op) + carpeta_destino=destino) self.assertEqual(expected, len(os.listdir(destino))) From 022bef42b29aecabdaa11db718adf6cfac7eab0a Mon Sep 17 00:00:00 2001 From: Patricio Paez Date: Sat, 4 Apr 2015 23:23:45 -0600 Subject: [PATCH 084/167] Actualizar las aplicaciones MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - admin-cfdi y descarga-cfdi llaman a _download_sat() después de instanciar DescargaSAT --- admin-cfdi | 6 +++--- descarga-cfdi | 3 ++- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/admin-cfdi b/admin-cfdi index b2f3474..5186a8c 100644 --- a/admin-cfdi +++ b/admin-cfdi @@ -381,9 +381,9 @@ class Application(pygubu.TkApplication): ok, data = self._validate_download_sat() if not ok: return - DescargaSAT(status_callback=self.msg_user, - download_callback=self.progress, - **data) + descarga = DescargaSAT(status_callback=self.msg_user, + download_callback=self.progress) + descarga._download_sat(**data) return def _validate_download_sat(self): diff --git a/descarga-cfdi b/descarga-cfdi index 245776d..f118e78 100755 --- a/descarga-cfdi +++ b/descarga-cfdi @@ -88,7 +88,8 @@ def main(): pwd = getpass.getpass('CIEC: ') else: rfc, pwd = open(args.archivo_de_credenciales).readline()[:-1].split() - descarga = DescargaSAT(facturas_emitidas= args.facturas_emitidas, + descarga = DescargaSAT() + descarga._download_sat(facturas_emitidas= args.facturas_emitidas, type_search=1 * (args.uuid != ''), rfc=rfc, ciec=pwd, From 65d2603da1b6623ca946c1fb353b464c559514c3 Mon Sep 17 00:00:00 2001 From: Patricio Paez Date: Sat, 4 Apr 2015 23:44:11 -0600 Subject: [PATCH 085/167] Prueba unitaria previa a refactorizar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Se desea separar _download_sat() en partes más pequeñas. - Empezando por sacar el bloque que define el perfil del navegador a get_firefox_profile() --- admincfdi/tests/test_pyutil.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 admincfdi/tests/test_pyutil.py diff --git a/admincfdi/tests/test_pyutil.py b/admincfdi/tests/test_pyutil.py new file mode 100644 index 0000000..6b3a9d0 --- /dev/null +++ b/admincfdi/tests/test_pyutil.py @@ -0,0 +1,16 @@ +import unittest + + +class DescargaSAT(unittest.TestCase): + + def test_get_firefox_profile(self): + from admincfdi.pyutil import DescargaSAT + from selenium import webdriver + + descarga = DescargaSAT() + profile = descarga.get_firefox_profile('carpeta_destino') + self.assertIsInstance(profile, webdriver.FirefoxProfile) + + +if __name__ == '__main__': + unittest.main() From 95bfa0b0388f443b27c1ed449c09848e014489bd Mon Sep 17 00:00:00 2001 From: Patricio Paez Date: Sun, 5 Apr 2015 00:00:16 -0600 Subject: [PATCH 086/167] =?UTF-8?q?Implementaci=C3=B3n=20de=20get=5Ffirefo?= =?UTF-8?q?x=5Fprofile()?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Reutiliza el código existente --- admincfdi/pyutil.py | 42 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/admincfdi/pyutil.py b/admincfdi/pyutil.py index 73613d9..72369ab 100644 --- a/admincfdi/pyutil.py +++ b/admincfdi/pyutil.py @@ -1672,6 +1672,48 @@ def __init__(self, status_callback=print, self.status = status_callback self.progress = download_callback + def get_firefox_profile(self, carpeta_destino): + 'Devuelve un perfil para Firefox' + + # To prevent download dialog + profile = webdriver.FirefoxProfile() + profile.set_preference( + 'browser.download.folderList', 2) + profile.set_preference( + 'browser.download.manager.showWhenStarting', False) + profile.set_preference( + 'browser.helperApps.alwaysAsk.force', False) + profile.set_preference( + 'browser.helperApps.neverAsk.saveToDisk', + 'text/xml, application/octet-stream, application/xml') + profile.set_preference( + 'browser.download.dir', carpeta_destino) + # mrE - desactivar telemetry + profile.set_preference( + 'toolkit.telemetry.prompted', 2) + profile.set_preference( + 'toolkit.telemetry.rejected', True) + profile.set_preference( + 'toolkit.telemetry.enabled', False) + profile.set_preference( + 'datareporting.healthreport.service.enabled', False) + profile.set_preference( + 'datareporting.healthreport.uploadEnabled', False) + profile.set_preference( + 'datareporting.healthreport.service.firstRun', False) + profile.set_preference( + 'datareporting.healthreport.logging.consoleEnabled', False) + profile.set_preference( + 'datareporting.policy.dataSubmissionEnabled', False) + profile.set_preference( + 'datareporting.policy.dataSubmissionPolicyResponseType', 'accepted-info-bar-dismissed') + #profile.set_preference( + # 'datareporting.policy.dataSubmissionPolicyAccepted'; False) # este me marca error, why? + #oculta la gran flecha animada al descargar + profile.set_preference( + 'browser.download.animateNotifications', False) + return profile + def _download_sat(self, facturas_emitidas=False, type_search=0, rfc='', ciec='', carpeta_destino='', From 10225d881aca7eccc078b00022e3adbeb5672cda Mon Sep 17 00:00:00 2001 From: Patricio Paez Date: Sun, 5 Apr 2015 00:07:49 -0600 Subject: [PATCH 087/167] Uso de get_firefox_profile() - El cambio se valida con la prueba funcional --- admincfdi/pyutil.py | 38 +------------------------------------- 1 file changed, 1 insertion(+), 37 deletions(-) diff --git a/admincfdi/pyutil.py b/admincfdi/pyutil.py index 72369ab..76c66c8 100644 --- a/admincfdi/pyutil.py +++ b/admincfdi/pyutil.py @@ -1726,43 +1726,7 @@ def _download_sat(self, facturas_emitidas=False, page_query = self.g.SAT['page_receptor'] if facturas_emitidas == 1: page_query = self.g.SAT['page_emisor'] - # To prevent download dialog - profile = webdriver.FirefoxProfile() - profile.set_preference( - 'browser.download.folderList', 2) - profile.set_preference( - 'browser.download.manager.showWhenStarting', False) - profile.set_preference( - 'browser.helperApps.alwaysAsk.force', False) - profile.set_preference( - 'browser.helperApps.neverAsk.saveToDisk', - 'text/xml, application/octet-stream, application/xml') - profile.set_preference( - 'browser.download.dir', carpeta_destino) - # mrE - desactivar telemetry - profile.set_preference( - 'toolkit.telemetry.prompted', 2) - profile.set_preference( - 'toolkit.telemetry.rejected', True) - profile.set_preference( - 'toolkit.telemetry.enabled', False) - profile.set_preference( - 'datareporting.healthreport.service.enabled', False) - profile.set_preference( - 'datareporting.healthreport.uploadEnabled', False) - profile.set_preference( - 'datareporting.healthreport.service.firstRun', False) - profile.set_preference( - 'datareporting.healthreport.logging.consoleEnabled', False) - profile.set_preference( - 'datareporting.policy.dataSubmissionEnabled', False) - profile.set_preference( - 'datareporting.policy.dataSubmissionPolicyResponseType', 'accepted-info-bar-dismissed') - #profile.set_preference( - # 'datareporting.policy.dataSubmissionPolicyAccepted'; False) # este me marca error, why? - #oculta la gran flecha animada al descargar - profile.set_preference( - 'browser.download.animateNotifications', False) + profile = self.get_firefox_profile(carpeta_destino) try: browser = webdriver.Firefox(profile) self.status('Conectando...') From 6c4978c1b0a9bc8cac04d49d78dc7db0bd352aa7 Mon Sep 17 00:00:00 2001 From: Patricio Paez Date: Sun, 5 Apr 2015 09:11:25 -0500 Subject: [PATCH 088/167] Crear DescargaSAT.connect() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Reutiliza el código existente - Persiste la referencia a la instancia de webbrowser que crea - Se valida con una prueba funcional. La sesión queda abierta y el navegador no se termina porque aún no tenemos DescargaSAT.disconnect() --- admincfdi/pyutil.py | 16 ++++++++++++++++ admincfdi/tests/test_pyutil.py | 31 +++++++++++++++++++++++++++++++ functional_DescargaSAT.py | 12 ++++++++++++ 3 files changed, 59 insertions(+) diff --git a/admincfdi/pyutil.py b/admincfdi/pyutil.py index 76c66c8..cd91db4 100644 --- a/admincfdi/pyutil.py +++ b/admincfdi/pyutil.py @@ -1671,6 +1671,7 @@ def __init__(self, status_callback=print, self.util = Util() self.status = status_callback self.progress = download_callback + self.browser = None def get_firefox_profile(self, carpeta_destino): 'Devuelve un perfil para Firefox' @@ -1714,6 +1715,21 @@ def get_firefox_profile(self, carpeta_destino): 'browser.download.animateNotifications', False) return profile + def connect(self, profile, rfc='', ciec=''): + 'Lanza navegador y hace login en el portal del SAT' + + browser = webdriver.Firefox(profile) + self.browser = browser + self.status('Conectando...') + browser.get(self.g.SAT['page_init']) + txt = browser.find_element_by_name(self.g.SAT['user']) + txt.send_keys(rfc) + txt = browser.find_element_by_name(self.g.SAT['password']) + txt.send_keys(ciec) + txt.submit() + time.sleep(3) + self.status('Conectado...') + def _download_sat(self, facturas_emitidas=False, type_search=0, rfc='', ciec='', carpeta_destino='', diff --git a/admincfdi/tests/test_pyutil.py b/admincfdi/tests/test_pyutil.py index 6b3a9d0..97971bf 100644 --- a/admincfdi/tests/test_pyutil.py +++ b/admincfdi/tests/test_pyutil.py @@ -3,6 +3,28 @@ class DescargaSAT(unittest.TestCase): + def setUp(self): + import time + from unittest.mock import Mock + from selenium import webdriver + from admincfdi import pyutil + + self.webdriver = pyutil.webdriver + pyutil.webdriver = Mock() + pyutil.webdriver.FirefoxProfile = webdriver.FirefoxProfile + + self.sleep = time.sleep + time.sleep = Mock() + + self.status = Mock() + + def tearDown(self): + import time + from admincfdi import pyutil + + pyutil.webdriver = self.webdriver + time.sleep = self.sleep + def test_get_firefox_profile(self): from admincfdi.pyutil import DescargaSAT from selenium import webdriver @@ -11,6 +33,15 @@ def test_get_firefox_profile(self): profile = descarga.get_firefox_profile('carpeta_destino') self.assertIsInstance(profile, webdriver.FirefoxProfile) + def test_connect(self): + from admincfdi.pyutil import DescargaSAT + from selenium import webdriver + + profile = webdriver.FirefoxProfile() + descarga = DescargaSAT(status_callback=self.status) + profile = descarga.connect(profile, rfc='x', ciec='y') + self.assertEqual(2, self.status.call_count) + if __name__ == '__main__': unittest.main() diff --git a/functional_DescargaSAT.py b/functional_DescargaSAT.py index 03c65ca..4ce8dda 100755 --- a/functional_DescargaSAT.py +++ b/functional_DescargaSAT.py @@ -11,6 +11,18 @@ def setUp(self): self.rfc, self.ciec = open('credenciales.conf').readline()[:-1].split() + def test_connect(self): + from admincfdi.pyutil import DescargaSAT + from selenium import webdriver + + profile = webdriver.FirefoxProfile() + + def no_op(*args): + pass + + descarga = DescargaSAT(status_callback=no_op) + descarga.connect(profile, rfc=self.rfc, ciec=self.ciec) + def test_uuid(self): import os import tempfile From 7966d83585d102a024a43b47ba26371900a1e648 Mon Sep 17 00:00:00 2001 From: Patricio Paez Date: Sun, 5 Apr 2015 09:53:05 -0500 Subject: [PATCH 089/167] Uso de DescargaSAT.connect() - El cambio se valida con las pruebas funcionales --- admincfdi/pyutil.py | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/admincfdi/pyutil.py b/admincfdi/pyutil.py index cd91db4..a006695 100644 --- a/admincfdi/pyutil.py +++ b/admincfdi/pyutil.py @@ -1744,16 +1744,8 @@ def _download_sat(self, facturas_emitidas=False, page_query = self.g.SAT['page_emisor'] profile = self.get_firefox_profile(carpeta_destino) try: - browser = webdriver.Firefox(profile) - self.status('Conectando...') - browser.get(self.g.SAT['page_init']) - txt = browser.find_element_by_name(self.g.SAT['user']) - txt.send_keys(rfc) - txt = browser.find_element_by_name(self.g.SAT['password']) - txt.send_keys(ciec) - txt.submit() - self.util.sleep(3) - self.status('Conectado...') + self.connect(profile, rfc=rfc, ciec=ciec) + browser = self.browser browser.get(page_query) self.util.sleep(3) self.status('Buscando...') From 7c8a72ab48bf4dad311b527c88764ecfb7e655db Mon Sep 17 00:00:00 2001 From: Patricio Paez Date: Sun, 5 Apr 2015 10:18:11 -0500 Subject: [PATCH 090/167] Crear DescargaSAT.disconnect() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Reutiliza el código existente - Se valida con una prueba funcional --- admincfdi/pyutil.py | 15 +++++++++++++++ admincfdi/tests/test_pyutil.py | 18 ++++++++++++++++++ functional_DescargaSAT.py | 3 ++- 3 files changed, 35 insertions(+), 1 deletion(-) diff --git a/admincfdi/pyutil.py b/admincfdi/pyutil.py index a006695..e7c3823 100644 --- a/admincfdi/pyutil.py +++ b/admincfdi/pyutil.py @@ -1730,6 +1730,21 @@ def connect(self, profile, rfc='', ciec=''): time.sleep(3) self.status('Conectado...') + def disconnect(self): + 'Cierra la sesión y el navegador' + + if self.browser: + try: + self.status('Desconectando...') + link = self.browser.find_element_by_partial_link_text('Cerrar Sesi') + link.click() + except: + pass + finally: + self.browser.close() + self.status('Desconectado...') + self.browser = None + def _download_sat(self, facturas_emitidas=False, type_search=0, rfc='', ciec='', carpeta_destino='', diff --git a/admincfdi/tests/test_pyutil.py b/admincfdi/tests/test_pyutil.py index 97971bf..5d1d8a9 100644 --- a/admincfdi/tests/test_pyutil.py +++ b/admincfdi/tests/test_pyutil.py @@ -42,6 +42,24 @@ def test_connect(self): profile = descarga.connect(profile, rfc='x', ciec='y') self.assertEqual(2, self.status.call_count) + def test_disconnect_not_connected(self): + from admincfdi.pyutil import DescargaSAT + from selenium import webdriver + + descarga = DescargaSAT(status_callback=self.status) + descarga.disconnect() + self.assertEqual(0, self.status.call_count) + + def test_disconnect(self): + from unittest.mock import Mock + from admincfdi.pyutil import DescargaSAT + from selenium import webdriver + + descarga = DescargaSAT(status_callback=self.status) + descarga.browser = Mock() + descarga.disconnect() + self.assertEqual(2, self.status.call_count) + if __name__ == '__main__': unittest.main() diff --git a/functional_DescargaSAT.py b/functional_DescargaSAT.py index 4ce8dda..2c57fac 100755 --- a/functional_DescargaSAT.py +++ b/functional_DescargaSAT.py @@ -11,7 +11,7 @@ def setUp(self): self.rfc, self.ciec = open('credenciales.conf').readline()[:-1].split() - def test_connect(self): + def test_connect_disconnect(self): from admincfdi.pyutil import DescargaSAT from selenium import webdriver @@ -22,6 +22,7 @@ def no_op(*args): descarga = DescargaSAT(status_callback=no_op) descarga.connect(profile, rfc=self.rfc, ciec=self.ciec) + descarga.disconnect() def test_uuid(self): import os From 0eb7bd5ec9dc081949902b99390368ff2c48e316 Mon Sep 17 00:00:00 2001 From: Patricio Paez Date: Sun, 5 Apr 2015 10:28:43 -0500 Subject: [PATCH 091/167] Uso de DescargaSAT.disconnect() - El cambio se valida con las pruebas funcionales --- admincfdi/pyutil.py | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/admincfdi/pyutil.py b/admincfdi/pyutil.py index e7c3823..a624cbf 100644 --- a/admincfdi/pyutil.py +++ b/admincfdi/pyutil.py @@ -1898,16 +1898,7 @@ def _download_sat(self, facturas_emitidas=False, except Exception as e: print (e) finally: - try: - self.status('Desconectando...') - link = browser.find_element_by_partial_link_text('Cerrar Sesi') - link.click() - except: - pass - finally: - browser.close() - self.status('Desconectado...') - return + self.disconnect() def _download_sat_month(self, año, mes, browser): '''Descarga CFDIs del SAT a una carpeta local From 313296b5f0f78339cadfb6aafc79465b2cf3dc22 Mon Sep 17 00:00:00 2001 From: Patricio Paez Date: Sun, 5 Apr 2015 17:46:25 -0500 Subject: [PATCH 092/167] Crear DescargaSAT.search() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Reutiliza el código existente, cubierto con pruebas unitarias - Se valida con una prueba funcional --- admincfdi/pyutil.py | 140 +++++++++++++++++++++++++++++++++ admincfdi/tests/test_pyutil.py | 118 +++++++++++++++++++++++++++ functional_DescargaSAT.py | 21 +++++ 3 files changed, 279 insertions(+) diff --git a/admincfdi/pyutil.py b/admincfdi/pyutil.py index a624cbf..12a7156 100644 --- a/admincfdi/pyutil.py +++ b/admincfdi/pyutil.py @@ -1745,6 +1745,146 @@ def disconnect(self): self.status('Desconectado...') self.browser = None + def search(self, facturas_emitidas=False, + type_search=0, + rfc='', ciec='', + uuid='', rfc_emisor='', + año=None, mes=None, día=None, + mes_completo_por_día=False): + 'Busca y regresa los resultados' + + if self.browser: + browser = self.browser + + page_query = self.g.SAT['page_receptor'] + if facturas_emitidas == 1: + page_query = self.g.SAT['page_emisor'] + + browser.get(page_query) + self.util.sleep(3) + self.status('Buscando...') + if type_search == 1: + txt = browser.find_element_by_id(self.g.SAT['uuid']) + txt.click() + txt.send_keys(uuid) + else: + # Descargar por fecha + opt = browser.find_element_by_id(self.g.SAT['date']) + opt.click() + self.util.sleep() + if rfc_emisor: + if type_search == 1: + txt = browser.find_element_by_id(self.g.SAT['receptor']) + else: + txt = browser.find_element_by_id(self.g.SAT['emisor']) + txt.send_keys(rfc_emisor) + # Emitidas + if facturas_emitidas == 1: + year = int(año) + month = int(mes) + dates = self.util.get_dates(year, month) + txt = browser.find_element_by_id(self.g.SAT['date_from']) + arg = "document.getElementsByName('{}')[0]." \ + "removeAttribute('disabled');".format( + self.g.SAT['date_from_name']) + browser.execute_script(arg) + txt.send_keys(dates[0]) + txt = browser.find_element_by_id(self.g.SAT['date_to']) + arg = "document.getElementsByName('{}')[0]." \ + "removeAttribute('disabled');".format( + self.g.SAT['date_to_name']) + browser.execute_script(arg) + txt.send_keys(dates[1]) + # Recibidas + else: + #~ combos = browser.find_elements_by_class_name( + #~ self.g.SAT['combos']) + #~ combos[0].click() + combo = browser.find_element_by_id(self.g.SAT['year']) + combo = browser.find_element_by_id( + 'sbToggle_{}'.format(combo.get_attribute('sb'))) + combo.click() + self.util.sleep(2) + link = browser.find_element_by_link_text(año) + link.click() + self.util.sleep(2) + combo = browser.find_element_by_id(self.g.SAT['month']) + combo = browser.find_element_by_id( + 'sbToggle_{}'.format(combo.get_attribute('sb'))) + combo.click() + self.util.sleep(2) + link = browser.find_element_by_link_text(mes) + link.click() + self.util.sleep(2) + if día != '00': + combo = browser.find_element_by_id(self.g.SAT['day']) + sb = combo.get_attribute('sb') + combo = browser.find_element_by_id( + 'sbToggle_{}'.format(sb)) + combo.click() + self.util.sleep() + if mes == día: + links = browser.find_elements_by_link_text(día) + for l in links: + p = l.find_element_by_xpath( + '..').find_element_by_xpath('..') + sb2 = p.get_attribute('id') + if sb in sb2: + link = l + break + else: + link = browser.find_element_by_link_text(día) + link.click() + self.util.sleep() + + browser.find_element_by_id(self.g.SAT['submit']).click() + sec = 3 + if facturas_emitidas != 1 and día == '00': + sec = 15 + self.util.sleep(sec) + # Bug del SAT + if facturas_emitidas != 1 and día != '00': + combo = browser.find_element_by_id(self.g.SAT['day']) + sb = combo.get_attribute('sb') + combo = browser.find_element_by_id( + 'sbToggle_{}'.format(sb)) + combo.click() + self.util.sleep(2) + if mes == día: + links = browser.find_elements_by_link_text(día) + for l in links: + p = l.find_element_by_xpath( + '..').find_element_by_xpath('..') + sb2 = p.get_attribute('id') + if sb in sb2: + link = l + break + else: + link = browser.find_element_by_link_text(día) + link.click() + self.util.sleep(2) + browser.find_element_by_id(self.g.SAT['submit']).click() + self.util.sleep(sec) + elif facturas_emitidas == 2 and mes_completo_por_día: + return self._download_sat_month(año, mes, browser) + + try: + found = True + content = browser.find_elements_by_class_name( + self.g.SAT['subtitle']) + for c in content: + if self.g.SAT['found'] in c.get_attribute('innerHTML') \ + and c.is_displayed(): + found = False + break + except Exception as e: + print (str(e)) + + if found: + docs = browser.find_elements_by_name(self.g.SAT['download']) + return docs + return [] + def _download_sat(self, facturas_emitidas=False, type_search=0, rfc='', ciec='', carpeta_destino='', diff --git a/admincfdi/tests/test_pyutil.py b/admincfdi/tests/test_pyutil.py index 5d1d8a9..af6a655 100644 --- a/admincfdi/tests/test_pyutil.py +++ b/admincfdi/tests/test_pyutil.py @@ -18,12 +18,15 @@ def setUp(self): self.status = Mock() + pyutil.print = Mock() + def tearDown(self): import time from admincfdi import pyutil pyutil.webdriver = self.webdriver time.sleep = self.sleep + del pyutil.print def test_get_firefox_profile(self): from admincfdi.pyutil import DescargaSAT @@ -60,6 +63,121 @@ def test_disconnect(self): descarga.disconnect() self.assertEqual(2, self.status.call_count) + def test_search_not_connected(self): + from admincfdi.pyutil import DescargaSAT + from selenium import webdriver + + profile = webdriver.FirefoxProfile() + descarga = DescargaSAT(status_callback=self.status) + results = descarga.search(uuid='uuid') + self.assertEqual(0, len(results)) + + def test_search_uuid(self): + from unittest.mock import MagicMock + from admincfdi.pyutil import DescargaSAT + from selenium import webdriver + + profile = webdriver.FirefoxProfile() + descarga = DescargaSAT(status_callback=self.status) + descarga.browser = MagicMock() + results = descarga.search(type_search=1, uuid='uuid', + día='00') + self.assertEqual(0, len(results)) + + def test_search_facturas_emitidas(self): + from unittest.mock import MagicMock + from admincfdi.pyutil import DescargaSAT + from selenium import webdriver + + profile = webdriver.FirefoxProfile() + descarga = DescargaSAT(status_callback=self.status) + descarga.browser = MagicMock() + results = descarga.search(facturas_emitidas=1, + año=1, mes=1) + self.assertEqual(0, len(results)) + + def test_search_rfc_emisor(self): + from unittest.mock import MagicMock + from admincfdi.pyutil import DescargaSAT + from selenium import webdriver + + profile = webdriver.FirefoxProfile() + descarga = DescargaSAT(status_callback=self.status) + descarga.browser = MagicMock() + results = descarga.search(rfc_emisor='x') + self.assertEqual(0, len(results)) + + def test_search_not_found(self): + from unittest.mock import MagicMock + from admincfdi.pyutil import DescargaSAT + from selenium import webdriver + + profile = webdriver.FirefoxProfile() + descarga = DescargaSAT(status_callback=self.status) + descarga.g.SAT['found'] = 'no' + descarga.browser = MagicMock() + c = MagicMock() + descarga.browser.find_elements_by_class_name.return_value = [c] + c.get_attribute.return_value = 'x no x' + c.is_displayed.return_value = True + results = descarga.search() + self.assertEqual(0, len(results)) + + def test_search_not_found_exception(self): + from unittest.mock import MagicMock + from admincfdi.pyutil import DescargaSAT + from selenium import webdriver + + profile = webdriver.FirefoxProfile() + descarga = DescargaSAT(status_callback=self.status) + descarga.browser = MagicMock() + c = MagicMock() + descarga.browser.find_elements_by_class_name.side_effect = Exception() + results = descarga.search() + self.assertEqual(0, len(results)) + + def test_search_mes_eq_día(self): + from unittest.mock import MagicMock + from admincfdi.pyutil import DescargaSAT + from selenium import webdriver + + profile = webdriver.FirefoxProfile() + descarga = DescargaSAT(status_callback=self.status) + descarga.browser = MagicMock() + link = MagicMock() + descarga.browser.find_elements_by_link_text.return_value = [link] + combo = descarga.browser.find_element_by_id.return_value + combo.get_attribute.return_value = 'sb' + r = link.find_element_by_xpath.return_value + p = r.find_element_by_xpath.return_value + p.get_attribute.return_value = 'sb2' + results = descarga.search(día='01', mes='01') + self.assertEqual(0, len(results)) + + def test_search_mes_completo_por_día(self): + from unittest.mock import MagicMock, Mock + from admincfdi.pyutil import DescargaSAT + from selenium import webdriver + + profile = webdriver.FirefoxProfile() + descarga = DescargaSAT(status_callback=self.status) + descarga.browser = MagicMock() + descarga._download_sat_month = Mock(return_value=[]) + results = descarga.search(facturas_emitidas=2, día='00', + mes_completo_por_día=True) + self.assertEqual(0, len(results)) + + def test_search_mes_ne_día(self): + from unittest.mock import MagicMock + from admincfdi.pyutil import DescargaSAT + from selenium import webdriver + + profile = webdriver.FirefoxProfile() + descarga = DescargaSAT(status_callback=self.status) + descarga.browser = MagicMock() + results = descarga.search(día='01', mes='02') + self.assertEqual(0, len(results)) + if __name__ == '__main__': unittest.main() diff --git a/functional_DescargaSAT.py b/functional_DescargaSAT.py index 2c57fac..70837f5 100755 --- a/functional_DescargaSAT.py +++ b/functional_DescargaSAT.py @@ -24,6 +24,27 @@ def no_op(*args): descarga.connect(profile, rfc=self.rfc, ciec=self.ciec) descarga.disconnect() + def test_search_uuid(self): + import os + import tempfile + from admincfdi.pyutil import DescargaSAT + + def no_op(*args): + pass + + seccion = self.config['uuid'] + uuid = seccion['uuid'] + expected = int(seccion['expected']) + descarga = DescargaSAT(status_callback=no_op, + download_callback=no_op) + profile = descarga.get_firefox_profile('destino') + descarga.connect(profile, rfc=self.rfc, ciec=self.ciec) + result = descarga.search(uuid=uuid, + type_search=1, + día='00') + descarga.disconnect() + self.assertEqual(expected, len(result)) + def test_uuid(self): import os import tempfile From 4cbcfa80b18abd43be9ab05fe28281193754b86a Mon Sep 17 00:00:00 2001 From: Patricio Paez Date: Sun, 5 Apr 2015 20:13:05 -0500 Subject: [PATCH 093/167] Crear DescargaSAT.download() --- admincfdi/pyutil.py | 15 +++++++++++++++ admincfdi/tests/test_pyutil.py | 11 +++++++++++ functional_DescargaSAT.py | 14 ++++++++------ 3 files changed, 34 insertions(+), 6 deletions(-) diff --git a/admincfdi/pyutil.py b/admincfdi/pyutil.py index 12a7156..7c21bde 100644 --- a/admincfdi/pyutil.py +++ b/admincfdi/pyutil.py @@ -1885,6 +1885,21 @@ def search(self, facturas_emitidas=False, return docs return [] + def download(self, docs): + 'Descarga los resultados' + + if self.browser: + t = len(docs) + for i, v in enumerate(docs): + msg = 'Factura {} de {}'.format(i+1, t) + self.progress(i + 1, t) + self.status(msg) + download = self.g.SAT['page_cfdi'].format( + v.get_attribute('onclick').split("'")[1]) + self.browser.get(download) + self.progress(0, t) + self.util.sleep() + def _download_sat(self, facturas_emitidas=False, type_search=0, rfc='', ciec='', carpeta_destino='', diff --git a/admincfdi/tests/test_pyutil.py b/admincfdi/tests/test_pyutil.py index af6a655..73b90cb 100644 --- a/admincfdi/tests/test_pyutil.py +++ b/admincfdi/tests/test_pyutil.py @@ -178,6 +178,17 @@ def test_search_mes_ne_día(self): results = descarga.search(día='01', mes='02') self.assertEqual(0, len(results)) + def test_download(self): + from unittest.mock import MagicMock + from admincfdi.pyutil import DescargaSAT + from selenium import webdriver + + profile = webdriver.FirefoxProfile() + descarga = DescargaSAT(status_callback=self.status) + descarga.browser = MagicMock() + docs = [MagicMock()] + descarga.download(docs) + if __name__ == '__main__': unittest.main() diff --git a/functional_DescargaSAT.py b/functional_DescargaSAT.py index 70837f5..68012fd 100755 --- a/functional_DescargaSAT.py +++ b/functional_DescargaSAT.py @@ -56,15 +56,17 @@ def no_op(*args): seccion = self.config['uuid'] uuid = seccion['uuid'] expected = int(seccion['expected']) + descarga = DescargaSAT(status_callback=no_op, + download_callback=no_op) with tempfile.TemporaryDirectory() as tempdir: destino = os.path.join(tempdir, 'cfdi-descarga') - descarga = DescargaSAT(status_callback=no_op, - download_callback=no_op) - descarga._download_sat(uuid=uuid, + profile = descarga.get_firefox_profile(destino) + descarga.connect(profile, rfc=self.rfc, ciec=self.ciec) + docs = descarga.search(uuid=uuid, type_search=1, - día='00', - rfc=self.rfc, ciec=self.ciec, - carpeta_destino=destino) + día='00') + descarga.download(docs) + descarga.disconnect() self.assertEqual(expected, len(os.listdir(destino))) def test_rfc(self): From a804316cf7f5d3171d652c1bc838ea5eb2c641d7 Mon Sep 17 00:00:00 2001 From: Patricio Paez Date: Sun, 5 Apr 2015 20:53:49 -0500 Subject: [PATCH 094/167] Agregar faltantes de _download_sat() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Status inicial - Status de búsqueda sin resultados --- admincfdi/pyutil.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/admincfdi/pyutil.py b/admincfdi/pyutil.py index 7c21bde..6a48c10 100644 --- a/admincfdi/pyutil.py +++ b/admincfdi/pyutil.py @@ -1718,6 +1718,7 @@ def get_firefox_profile(self, carpeta_destino): def connect(self, profile, rfc='', ciec=''): 'Lanza navegador y hace login en el portal del SAT' + self.status('Abriendo Firefox...') browser = webdriver.Firefox(profile) self.browser = browser self.status('Conectando...') @@ -1883,6 +1884,8 @@ def search(self, facturas_emitidas=False, if found: docs = browser.find_elements_by_name(self.g.SAT['download']) return docs + else: + self.status('Sin facturas...') return [] def download(self, docs): From 2b8ebe5b3e99277ea2f2bb9630258a3e5c60105d Mon Sep 17 00:00:00 2001 From: Patricio Paez Date: Sun, 5 Apr 2015 21:00:55 -0500 Subject: [PATCH 095/167] Actualizar las aplicaciones MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - admin-cfdi y descarga-cfdi dejan de usar _download_sat(), se remplaza por connect(), search(), download() y disconnect() - Se conserva el try/except/finally que asegura la desconexión si hay algún problema - Se actualizan las pruebas funcionales --- admin-cfdi | 17 ++++++++++++++++- descarga-cfdi | 27 +++++++++++++++----------- functional_DescargaSAT.py | 40 ++++++++++++++++++++++----------------- 3 files changed, 55 insertions(+), 29 deletions(-) diff --git a/admin-cfdi b/admin-cfdi index 5186a8c..340ae69 100644 --- a/admin-cfdi +++ b/admin-cfdi @@ -383,7 +383,22 @@ class Application(pygubu.TkApplication): return descarga = DescargaSAT(status_callback=self.msg_user, download_callback=self.progress) - descarga._download_sat(**data) + profile = descarga.get_firefox_profile(data['carpeta_destino']) + try: + descarga.connect(profile, rfc=data['rfc'], ciec=data['ciec']) + docs = descarga.search(facturas_emitidas=data['facturas_emitidas'], + type_search=1 * (data['uuid'] != ''), + uuid=data['uuid'], + rfc_emisor=data['rfc_emisor'], + año=data['año'], + mes=data['mes'], + día=data['día'], + mes_completo_por_día=data['mes_completo_por_día']) + descarga.download(docs) + except Exception as e: + print (e) + finally: + descarga.disconnect() return def _validate_download_sat(self): diff --git a/descarga-cfdi b/descarga-cfdi index f118e78..0282172 100755 --- a/descarga-cfdi +++ b/descarga-cfdi @@ -89,17 +89,22 @@ def main(): else: rfc, pwd = open(args.archivo_de_credenciales).readline()[:-1].split() descarga = DescargaSAT() - descarga._download_sat(facturas_emitidas= args.facturas_emitidas, - type_search=1 * (args.uuid != ''), - rfc=rfc, - ciec=pwd, - carpeta_destino=args.carpeta_destino, - uuid=args.uuid, - rfc_emisor=args.rfc_emisor, - año=args.año, - mes=args.mes, - día=args.día, - mes_completo_por_día=args.mes_completo_por_día) + profile = descarga.get_firefox_profile(args.carpeta_destino) + try: + descarga.connect(profile, rfc=rfc, ciec=pwd) + docs = descarga.search(facturas_emitidas= args.facturas_emitidas, + type_search=1 * (args.uuid != ''), + uuid=args.uuid, + rfc_emisor=args.rfc_emisor, + año=args.año, + mes=args.mes, + día=args.día, + mes_completo_por_día=args.mes_completo_por_día) + descarga.download(docs) + except Exception as e: + print (e) + finally: + descarga.disconnect() if __name__ == '__main__': diff --git a/functional_DescargaSAT.py b/functional_DescargaSAT.py index 68012fd..ebe6ac3 100755 --- a/functional_DescargaSAT.py +++ b/functional_DescargaSAT.py @@ -83,14 +83,16 @@ def no_op(*args): mes = seccion['mes'] día = seccion['día'] expected = int(seccion['expected']) + descarga = DescargaSAT(status_callback=no_op, + download_callback=no_op) with tempfile.TemporaryDirectory() as tempdir: destino = os.path.join(tempdir, 'cfdi-descarga') - descarga = DescargaSAT(status_callback=no_op, - download_callback=no_op) - descarga._download_sat(año=año, mes=mes, día=día, - rfc_emisor=rfc_emisor, - rfc=self.rfc, ciec=self.ciec, - carpeta_destino=destino) + profile = descarga.get_firefox_profile(destino) + descarga.connect(profile, rfc=self.rfc, ciec=self.ciec) + docs = descarga.search(año=año, mes=mes, día=día, + rfc_emisor=rfc_emisor) + descarga.download(docs) + descarga.disconnect() self.assertEqual(expected, len(os.listdir(destino))) def test_año_mes_día(self): @@ -106,13 +108,15 @@ def no_op(*args): mes = seccion['mes'] día = seccion['día'] expected = int(seccion['expected']) + descarga = DescargaSAT(status_callback=no_op, + download_callback=no_op) with tempfile.TemporaryDirectory() as tempdir: destino = os.path.join(tempdir, 'cfdi-descarga') - descarga = DescargaSAT(status_callback=no_op, - download_callback=no_op) - descarga._download_sat(año=año, mes=mes, día=día, - rfc=self.rfc, ciec=self.ciec, - carpeta_destino=destino) + profile = descarga.get_firefox_profile(destino) + descarga.connect(profile, rfc=self.rfc, ciec=self.ciec) + docs = descarga.search(año=año, mes=mes, día=día) + descarga.download(docs) + descarga.disconnect() self.assertEqual(expected, len(os.listdir(destino))) def test_mes_completo(self): @@ -127,14 +131,16 @@ def no_op(*args): año = seccion['año'] mes = seccion['mes'] expected = int(seccion['expected']) + descarga = DescargaSAT(status_callback=no_op, + download_callback=no_op) with tempfile.TemporaryDirectory() as tempdir: destino = os.path.join(tempdir, 'cfdi-descarga') - descarga = DescargaSAT(status_callback=no_op, - download_callback=no_op) - descarga._download_sat(año=año, mes=mes, día='00', - mes_completo_por_día=True, - rfc=self.rfc, ciec=self.ciec, - carpeta_destino=destino) + profile = descarga.get_firefox_profile(destino) + descarga.connect(profile, rfc=self.rfc, ciec=self.ciec) + docs = descarga.search(año=año, mes=mes, día='00', + mes_completo_por_día=True) + descarga.download(docs) + descarga.disconnect() self.assertEqual(expected, len(os.listdir(destino))) From cb1dbe0dbb49a47dc8ae727c1f9fbb21fb167797 Mon Sep 17 00:00:00 2001 From: Patricio Paez Date: Sun, 5 Apr 2015 21:49:58 -0500 Subject: [PATCH 096/167] Actualizar las pruebas unitarias --- admincfdi/tests/test_pyutil.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/admincfdi/tests/test_pyutil.py b/admincfdi/tests/test_pyutil.py index 73b90cb..f4897f0 100644 --- a/admincfdi/tests/test_pyutil.py +++ b/admincfdi/tests/test_pyutil.py @@ -17,6 +17,7 @@ def setUp(self): time.sleep = Mock() self.status = Mock() + self.transfer = Mock() pyutil.print = Mock() @@ -43,7 +44,7 @@ def test_connect(self): profile = webdriver.FirefoxProfile() descarga = DescargaSAT(status_callback=self.status) profile = descarga.connect(profile, rfc='x', ciec='y') - self.assertEqual(2, self.status.call_count) + self.assertEqual(3, self.status.call_count) def test_disconnect_not_connected(self): from admincfdi.pyutil import DescargaSAT @@ -184,7 +185,8 @@ def test_download(self): from selenium import webdriver profile = webdriver.FirefoxProfile() - descarga = DescargaSAT(status_callback=self.status) + descarga = DescargaSAT(status_callback=self.status, + download_callback=self.transfer) descarga.browser = MagicMock() docs = [MagicMock()] descarga.download(docs) From 9c8deeb75139243f6e9e143a1dfc13430b2c6659 Mon Sep 17 00:00:00 2001 From: Patricio Paez Date: Sun, 5 Apr 2015 21:52:32 -0500 Subject: [PATCH 097/167] Remover _download_sat() --- admincfdi/pyutil.py | 155 -------------------------------------------- 1 file changed, 155 deletions(-) diff --git a/admincfdi/pyutil.py b/admincfdi/pyutil.py index 6a48c10..da2dbb1 100644 --- a/admincfdi/pyutil.py +++ b/admincfdi/pyutil.py @@ -1903,161 +1903,6 @@ def download(self, docs): self.progress(0, t) self.util.sleep() - def _download_sat(self, facturas_emitidas=False, - type_search=0, - rfc='', ciec='', carpeta_destino='', - uuid='', rfc_emisor='', - año=None, mes=None, día=None, - mes_completo_por_día=False): - 'Descarga CFDIs del SAT a una carpeta local' - - self.status('Abriendo Firefox...') - page_query = self.g.SAT['page_receptor'] - if facturas_emitidas == 1: - page_query = self.g.SAT['page_emisor'] - profile = self.get_firefox_profile(carpeta_destino) - try: - self.connect(profile, rfc=rfc, ciec=ciec) - browser = self.browser - browser.get(page_query) - self.util.sleep(3) - self.status('Buscando...') - if type_search == 1: - txt = browser.find_element_by_id(self.g.SAT['uuid']) - txt.click() - txt.send_keys(uuid) - else: - # Descargar por fecha - opt = browser.find_element_by_id(self.g.SAT['date']) - opt.click() - self.util.sleep() - if rfc_emisor: - if type_search == 1: - txt = browser.find_element_by_id(self.g.SAT['receptor']) - else: - txt = browser.find_element_by_id(self.g.SAT['emisor']) - txt.send_keys(rfc_emisor) - # Emitidas - if facturas_emitidas == 1: - year = int(año) - month = int(mes) - dates = self.util.get_dates(year, month) - txt = browser.find_element_by_id(self.g.SAT['date_from']) - arg = "document.getElementsByName('{}')[0]." \ - "removeAttribute('disabled');".format( - self.g.SAT['date_from_name']) - browser.execute_script(arg) - txt.send_keys(dates[0]) - txt = browser.find_element_by_id(self.g.SAT['date_to']) - arg = "document.getElementsByName('{}')[0]." \ - "removeAttribute('disabled');".format( - self.g.SAT['date_to_name']) - browser.execute_script(arg) - txt.send_keys(dates[1]) - # Recibidas - else: - #~ combos = browser.find_elements_by_class_name( - #~ self.g.SAT['combos']) - #~ combos[0].click() - combo = browser.find_element_by_id(self.g.SAT['year']) - combo = browser.find_element_by_id( - 'sbToggle_{}'.format(combo.get_attribute('sb'))) - combo.click() - self.util.sleep(2) - link = browser.find_element_by_link_text(año) - link.click() - self.util.sleep(2) - combo = browser.find_element_by_id(self.g.SAT['month']) - combo = browser.find_element_by_id( - 'sbToggle_{}'.format(combo.get_attribute('sb'))) - combo.click() - self.util.sleep(2) - link = browser.find_element_by_link_text(mes) - link.click() - self.util.sleep(2) - if día != '00': - combo = browser.find_element_by_id(self.g.SAT['day']) - sb = combo.get_attribute('sb') - combo = browser.find_element_by_id( - 'sbToggle_{}'.format(sb)) - combo.click() - self.util.sleep() - if mes == día: - links = browser.find_elements_by_link_text(día) - for l in links: - p = l.find_element_by_xpath( - '..').find_element_by_xpath('..') - sb2 = p.get_attribute('id') - if sb in sb2: - link = l - break - else: - link = browser.find_element_by_link_text(día) - link.click() - self.util.sleep() - - browser.find_element_by_id(self.g.SAT['submit']).click() - sec = 3 - if facturas_emitidas != 1 and día == '00': - sec = 15 - self.util.sleep(sec) - # Bug del SAT - if facturas_emitidas != 1 and día != '00': - combo = browser.find_element_by_id(self.g.SAT['day']) - sb = combo.get_attribute('sb') - combo = browser.find_element_by_id( - 'sbToggle_{}'.format(sb)) - combo.click() - self.util.sleep(2) - if mes == día: - links = browser.find_elements_by_link_text(día) - for l in links: - p = l.find_element_by_xpath( - '..').find_element_by_xpath('..') - sb2 = p.get_attribute('id') - if sb in sb2: - link = l - break - else: - link = browser.find_element_by_link_text(día) - link.click() - self.util.sleep(2) - browser.find_element_by_id(self.g.SAT['submit']).click() - self.util.sleep(sec) - elif facturas_emitidas == 2 and mes_completo_por_día: - return self._download_sat_month(año, mes, browser) - - try: - found = True - content = browser.find_elements_by_class_name( - self.g.SAT['subtitle']) - for c in content: - if self.g.SAT['found'] in c.get_attribute('innerHTML') \ - and c.is_displayed(): - found = False - break - except Exception as e: - print (str(e)) - - if found: - docs = browser.find_elements_by_name(self.g.SAT['download']) - t = len(docs) - for i, v in enumerate(docs): - msg = 'Factura {} de {}'.format(i+1, t) - self.progress(i + 1, t) - self.status(msg) - download = self.g.SAT['page_cfdi'].format( - v.get_attribute('onclick').split("'")[1]) - browser.get(download) - self.progress(0, t) - self.util.sleep() - else: - self.status('Sin facturas...') - except Exception as e: - print (e) - finally: - self.disconnect() - def _download_sat_month(self, año, mes, browser): '''Descarga CFDIs del SAT a una carpeta local From a34362fd830b55283dc3966968648fa510ff4515 Mon Sep 17 00:00:00 2001 From: Patricio Paez Date: Sun, 12 Apr 2015 21:11:51 -0500 Subject: [PATCH 098/167] =?UTF-8?q?Actualizar=20la=20referencia=20a=20los?= =?UTF-8?q?=20m=C3=B3dulos?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/reference.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/reference.rst b/docs/reference.rst index 08c1c52..7ff63dc 100644 --- a/docs/reference.rst +++ b/docs/reference.rst @@ -13,7 +13,7 @@ admincfdi pyutil ------ -.. automodule:: pyutil +.. automodule:: admincfdi.pyutil :members: :undoc-members: :private-members: @@ -21,7 +21,7 @@ pyutil values ------ -.. automodule:: values +.. automodule:: admincfdi.values :members: :undoc-members: :private-members: From 8e692c44727fe2ac73acb8d6975dc80ca029943c Mon Sep 17 00:00:00 2001 From: Patricio Paez Date: Sun, 12 Apr 2015 21:12:21 -0500 Subject: [PATCH 099/167] =?UTF-8?q?Comenzar=20la=20documentaci=C3=B3n=20de?= =?UTF-8?q?=20la=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Una sección para cada clase - Uso de DescargaSAT y ejemplo --- docs/devel.rst | 101 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 101 insertions(+) diff --git a/docs/devel.rst b/docs/devel.rst index ce803f2..4c3f6bf 100644 --- a/docs/devel.rst +++ b/docs/devel.rst @@ -106,3 +106,104 @@ de la interfase gráfica, en esta secuencia:: Factura 12 de 12 Desconectando... Desconectado... + +API +=== +El módulo :mod:`admincfdi.pyutil` provee varias clases, las cuales +pueden ser usadas por las aplicaciones. En las siguientes +secciones se explican y dan ejemplos de uso cada una de estas clases. + + +SAT +--- + +ValidCFDI +--------- + +Util +---- + +Mail +---- + +LibO +---- + +NumerosLetras +------------- + +CFDIPDF +------- + +DescargaSAT +----------- +Lleva a cabo al descarga de CFDIs del sitio del SAT. Para descargar +un conjunto de CFDIs con ciertos criterios de búsqueda, se +utilizan los siguientes pasos: + +#. Instanciar :class:`~admincfdi.pyutil.DescargaSAT`:: + + descarga = DescargaSAT() + +#. Crear un perfil de Firefox:: + + profile = descarga.get_firefox_profile(carpeta_destino) + +#. Conectar al sitio del SAT, lanzando Firefox:: + + descarga.connect(profile, rfc=rfc, ciec=pwd) + +#. Realizar una búsqueda, guardando la lista de resultados + obtenida:: + + docs = descarga.search(facturas_emitidas=facturas_emitidas, + type_search=1 * (uuid != ''), + uuid=uuid, + rfc_emisor=rfc_emisor, + año=año, + mes=mes, + día=día, + mes_completo_por_día=mes_completo_por_día) + +#. Descargar los CFDIs:: + + descarga.download(docs) + +#. Desconectar la sesión del sitio del SAT y terminar + Firefox:: + + descarga.disconnect() + +Los pasos 4. de búsqueda y 5. de descarga pueden repetirse, si +se desean descargar dos o más conjuntos de CFDIs con diferentes +criterios de búsqueda, manteniendo la sesión original abierta. + +Como ejemplo, a continuación se muestra el uso de los +pasos en las aplicaciones ``admin-cfdi`` y ``descarga-cfdi`` +que son parte del proyecto:: + + descarga = DescargaSAT() + profile = descarga.get_firefox_profile(args.carpeta_destino) + try: + descarga.connect(profile, rfc=rfc, ciec=pwd) + docs = descarga.search(facturas_emitidas= args.facturas_emitidas, + type_search=1 * (args.uuid != ''), + uuid=args.uuid, + rfc_emisor=args.rfc_emisor, + año=args.año, + mes=args.mes, + día=args.día, + mes_completo_por_día=args.mes_completo_por_día) + descarga.download(docs) + except Exception as e: + print (e) + finally: + descarga.disconnect() + +Las cláusulas ``try/except/finally`` son para manejar alguna +excepción que ocurra en cualquiera de los pasos, y garantizar +que en cualquier caso se hace la desconexión de la sesión +y se termina Firefox. + +CSVPDF +------ From 63f3ea7c74db2f7a43abd9843b72a7fb915e1f93 Mon Sep 17 00:00:00 2001 From: Patricio Paez Date: Sun, 12 Apr 2015 21:36:39 -0500 Subject: [PATCH 100/167] Documentar el uso de las pruebas funcionales --- docs/uso.rst | 73 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) diff --git a/docs/uso.rst b/docs/uso.rst index 93e46dc..cee18e2 100644 --- a/docs/uso.rst +++ b/docs/uso.rst @@ -1,3 +1,76 @@ === Uso === + + +Pruebas funcionales de descarga del SAT +--------------------------------------- +Estas pruebas sirven para varios propósitos: + +- Saber si el sitio del SAT esta funcionando + normalmente, + +- Saber si nuestra conexión entre la PC + y el sito del SAT está funcionando y si + su desempeño es el esperado, + +- Saber si el sitio del SAT cambió su + funcionamiento del tal forma que sea + necesario actualizar la librería de + descarga de admincfdi. + +Las pruebas realizan descargas mediante +varios modos de búsqueda y validan +que la cantidad de archivos descargados +sea la esperada. No requieren interacción +mientas corren. + +Es necesario crear un archivo de credenciales y un archivo de +configuración para las pruebas. El archivo de configuración +especifica los criterios de cada búsqueda. Este es un ejemplo:: + + [uuid] + uuid=XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX + expected=1 + + [rfc_emisor] + rfc_emisor=XXXXXXXXXXXX + año=2014 + mes=09 + día=26 + expected=1 + + [año_mes_día] + año=2014 + mes=09 + día=26 + expected=1 + + [mes_completo_por_día] + año=2014 + mes=09 + expected=5 + +Se necesitan estas cuatro secciones. Hay que ajustar los +valores para que la cantidad de CFDIs no sea muy grande. La +variable ``expected`` se ajusta a la cantidad de CFDIs que se +descargan, para las credenciales que se utilicen. + +Para ejecutar:: + + python functional_DescargaSAT.py + .... + ---------------------------------------------------------------------- + Ran 4 tests in 254.376s + +Agregar el parámetro ``-v`` para tener un renglón por +cada prueba que se ejecuta:: + + python functional_DescargaSAT.py -v + test_año_mes_día (__main__.DescargaSAT) ... ok + test_mes_completo (__main__.DescargaSAT) ... ok + test_rfc (__main__.DescargaSAT) ... ok + test_uuid (__main__.DescargaSAT) ... ok + + ---------------------------------------------------------------------- + Ran 4 tests in 254.376s From 6c8dafc706debcbc22b2f1f98ea88c08514eab86 Mon Sep 17 00:00:00 2001 From: Mauricio Baeza Date: Wed, 15 Apr 2015 17:49:27 -0500 Subject: [PATCH 101/167] Se reemplaza pysimplesoap por request para verificar estatus en el SAT --- {bin => admincfdi/bin}/cadena3.2.xslt | 0 {bin => admincfdi/bin}/cfdi_3.2.xsd | 0 {bin => admincfdi/bin}/cfdi_3.2.xslt | 0 {bin => admincfdi/bin}/get_certificado.xslt | 0 {bin => admincfdi/bin}/get_sello.xslt | 0 {bin => admincfdi/bin}/get_sello_sat.xslt | 0 {bin => admincfdi/bin}/iconv.dll | Bin {bin => admincfdi/bin}/libeay32.dll | Bin {bin => admincfdi/bin}/libexslt.dll | Bin {bin => admincfdi/bin}/libxml2.dll | Bin {bin => admincfdi/bin}/libxslt.dll | Bin {bin => admincfdi/bin}/openssl.exe | Bin {bin => admincfdi/bin}/ssleay32.dll | Bin {bin => admincfdi/bin}/timbre.xslt | 0 {bin => admincfdi/bin}/timbre_1.0.xslt | 0 {bin => admincfdi/bin}/xsltproc.exe | Bin {bin => admincfdi/bin}/zlib1.dll | Bin .../cer_pac}/00001000000103834451.cer | Bin .../cer_pac}/00001000000104731997.cer | Bin .../cer_pac}/00001000000104750010.cer | Bin .../cer_pac}/00001000000104871381.cer | Bin .../cer_pac}/00001000000104888042.cer | Bin .../cer_pac}/00001000000200005634.cer | Bin .../cer_pac}/00001000000200011997.cer | Bin .../cer_pac}/00001000000200365214.cer | Bin .../cer_pac}/00001000000200795916.cer | Bin .../cer_pac}/00001000000201345662.cer | Bin .../cer_pac}/00001000000201345708.cer | Bin .../cer_pac}/00001000000201395217.cer | Bin .../cer_pac}/00001000000201455572.cer | Bin .../cer_pac}/00001000000201614141.cer | Bin .../cer_pac}/00001000000201629292.cer | Bin .../cer_pac}/00001000000201748120.cer | Bin .../cer_pac}/00001000000202241710.cer | Bin .../cer_pac}/00001000000202453260.cer | Bin .../cer_pac}/00001000000202638162.cer | Bin .../cer_pac}/00001000000202639096.cer | Bin .../cer_pac}/00001000000202639521.cer | Bin .../cer_pac}/00001000000202693892.cer | Bin .../cer_pac}/00001000000202695775.cer | Bin .../cer_pac}/00001000000202700691.cer | Bin .../cer_pac}/00001000000202771790.cer | Bin .../cer_pac}/00001000000202772539.cer | Bin .../cer_pac}/00001000000202809550.cer | Bin .../cer_pac}/00001000000202864285.cer | Bin .../cer_pac}/00001000000202864530.cer | Bin .../cer_pac}/00001000000202864883.cer | Bin .../cer_pac}/00001000000202865018.cer | Bin .../cer_pac}/00001000000202905407.cer | Bin .../cer_pac}/00001000000203015571.cer | Bin .../cer_pac}/00001000000203051706.cer | Bin .../cer_pac}/00001000000203082087.cer | Bin .../cer_pac}/00001000000203092957.cer | Bin .../cer_pac}/00001000000203093174.cer | Bin .../cer_pac}/00001000000203159220.cer | Bin .../cer_pac}/00001000000203159375.cer | Bin .../cer_pac}/00001000000203191015.cer | Bin .../cer_pac}/00001000000203220518.cer | Bin .../cer_pac}/00001000000203220546.cer | Bin .../cer_pac}/00001000000203253077.cer | Bin .../cer_pac}/00001000000203285726.cer | Bin .../cer_pac}/00001000000203285735.cer | Bin .../cer_pac}/00001000000203292609.cer | Bin .../cer_pac}/00001000000203312933.cer | Bin .../cer_pac}/00001000000203352843.cer | Bin .../cer_pac}/00001000000203392777.cer | Bin .../cer_pac}/00001000000203430011.cer | Bin .../cer_pac}/00001000000203495276.cer | Bin .../cer_pac}/00001000000203495475.cer | Bin .../cer_pac}/00001000000203631919.cer | Bin .../cer_pac}/00001000000300091673.cer | Bin .../cer_pac}/00001000000300171291.cer | Bin .../cer_pac}/00001000000300171326.cer | Bin .../cer_pac}/00001000000300209963.cer | Bin .../cer_pac}/00001000000300250292.cer | Bin .../cer_pac}/00001000000300392385.cer | Bin .../cer_pac}/00001000000300407877.cer | Bin .../cer_pac}/00001000000300439968.cer | Bin .../cer_pac}/00001000000300494998.cer | Bin .../cer_pac}/00001000000300627194.cer | Bin .../cer_pac}/00001000000300716418.cer | Bin .../cer_pac}/00001000000300716428.cer | Bin .../cer_pac}/00001000000300774022.cer | Bin .../cer_pac}/00001000000300915978.cer | Bin .../cer_pac}/00001000000300969660.cer | Bin .../cer_pac}/00001000000301021501.cer | Bin .../cer_pac}/00001000000301032322.cer | Bin .../cer_pac}/00001000000301062628.cer | Bin .../cer_pac}/00001000000301083052.cer | Bin .../cer_pac}/00001000000301100488.cer | Bin .../cer_pac}/00001000000301160463.cer | Bin .../cer_pac}/00001000000301280594.cer | Bin .../cer_pac}/00001000000301567711.cer | Bin .../cer_pac}/00001000000301634628.cer | Bin .../cer_pac}/00001000000301751173.cer | Bin .../cer_pac}/00001000000301914249.cer | Bin .../cer_pac}/00001000000301927035.cer | Bin .../cer_pac}/00001000000301949314.cer | Bin .../cer_pac}/00001000000304339685.cer | Bin .../cer_pac}/00001000000304691381.cer | Bin .../cer_pac}/20001000000100005868.cer | Bin admincfdi/pyutil.py | 50 ++++++++++++------ setup.py | 2 +- 103 files changed, 36 insertions(+), 16 deletions(-) rename {bin => admincfdi/bin}/cadena3.2.xslt (100%) rename {bin => admincfdi/bin}/cfdi_3.2.xsd (100%) rename {bin => admincfdi/bin}/cfdi_3.2.xslt (100%) rename {bin => admincfdi/bin}/get_certificado.xslt (100%) rename {bin => admincfdi/bin}/get_sello.xslt (100%) rename {bin => admincfdi/bin}/get_sello_sat.xslt (100%) rename {bin => admincfdi/bin}/iconv.dll (100%) rename {bin => admincfdi/bin}/libeay32.dll (100%) rename {bin => admincfdi/bin}/libexslt.dll (100%) rename {bin => admincfdi/bin}/libxml2.dll (100%) rename {bin => admincfdi/bin}/libxslt.dll (100%) rename {bin => admincfdi/bin}/openssl.exe (100%) rename {bin => admincfdi/bin}/ssleay32.dll (100%) rename {bin => admincfdi/bin}/timbre.xslt (100%) rename {bin => admincfdi/bin}/timbre_1.0.xslt (100%) rename {bin => admincfdi/bin}/xsltproc.exe (100%) rename {bin => admincfdi/bin}/zlib1.dll (100%) rename {cer_pac => admincfdi/cer_pac}/00001000000103834451.cer (100%) rename {cer_pac => admincfdi/cer_pac}/00001000000104731997.cer (100%) rename {cer_pac => admincfdi/cer_pac}/00001000000104750010.cer (100%) rename {cer_pac => admincfdi/cer_pac}/00001000000104871381.cer (100%) rename {cer_pac => admincfdi/cer_pac}/00001000000104888042.cer (100%) rename {cer_pac => admincfdi/cer_pac}/00001000000200005634.cer (100%) rename {cer_pac => admincfdi/cer_pac}/00001000000200011997.cer (100%) rename {cer_pac => admincfdi/cer_pac}/00001000000200365214.cer (100%) rename {cer_pac => admincfdi/cer_pac}/00001000000200795916.cer (100%) rename {cer_pac => admincfdi/cer_pac}/00001000000201345662.cer (100%) rename {cer_pac => admincfdi/cer_pac}/00001000000201345708.cer (100%) rename {cer_pac => admincfdi/cer_pac}/00001000000201395217.cer (100%) rename {cer_pac => admincfdi/cer_pac}/00001000000201455572.cer (100%) rename {cer_pac => admincfdi/cer_pac}/00001000000201614141.cer (100%) rename {cer_pac => admincfdi/cer_pac}/00001000000201629292.cer (100%) rename {cer_pac => admincfdi/cer_pac}/00001000000201748120.cer (100%) rename {cer_pac => admincfdi/cer_pac}/00001000000202241710.cer (100%) rename {cer_pac => admincfdi/cer_pac}/00001000000202453260.cer (100%) rename {cer_pac => admincfdi/cer_pac}/00001000000202638162.cer (100%) rename {cer_pac => admincfdi/cer_pac}/00001000000202639096.cer (100%) rename {cer_pac => admincfdi/cer_pac}/00001000000202639521.cer (100%) rename {cer_pac => admincfdi/cer_pac}/00001000000202693892.cer (100%) rename {cer_pac => admincfdi/cer_pac}/00001000000202695775.cer (100%) rename {cer_pac => admincfdi/cer_pac}/00001000000202700691.cer (100%) rename {cer_pac => admincfdi/cer_pac}/00001000000202771790.cer (100%) rename {cer_pac => admincfdi/cer_pac}/00001000000202772539.cer (100%) rename {cer_pac => admincfdi/cer_pac}/00001000000202809550.cer (100%) rename {cer_pac => admincfdi/cer_pac}/00001000000202864285.cer (100%) rename {cer_pac => admincfdi/cer_pac}/00001000000202864530.cer (100%) rename {cer_pac => admincfdi/cer_pac}/00001000000202864883.cer (100%) rename {cer_pac => admincfdi/cer_pac}/00001000000202865018.cer (100%) rename {cer_pac => admincfdi/cer_pac}/00001000000202905407.cer (100%) rename {cer_pac => admincfdi/cer_pac}/00001000000203015571.cer (100%) rename {cer_pac => admincfdi/cer_pac}/00001000000203051706.cer (100%) rename {cer_pac => admincfdi/cer_pac}/00001000000203082087.cer (100%) rename {cer_pac => admincfdi/cer_pac}/00001000000203092957.cer (100%) rename {cer_pac => admincfdi/cer_pac}/00001000000203093174.cer (100%) rename {cer_pac => admincfdi/cer_pac}/00001000000203159220.cer (100%) rename {cer_pac => admincfdi/cer_pac}/00001000000203159375.cer (100%) rename {cer_pac => admincfdi/cer_pac}/00001000000203191015.cer (100%) rename {cer_pac => admincfdi/cer_pac}/00001000000203220518.cer (100%) rename {cer_pac => admincfdi/cer_pac}/00001000000203220546.cer (100%) rename {cer_pac => admincfdi/cer_pac}/00001000000203253077.cer (100%) rename {cer_pac => admincfdi/cer_pac}/00001000000203285726.cer (100%) rename {cer_pac => admincfdi/cer_pac}/00001000000203285735.cer (100%) rename {cer_pac => admincfdi/cer_pac}/00001000000203292609.cer (100%) rename {cer_pac => admincfdi/cer_pac}/00001000000203312933.cer (100%) rename {cer_pac => admincfdi/cer_pac}/00001000000203352843.cer (100%) rename {cer_pac => admincfdi/cer_pac}/00001000000203392777.cer (100%) rename {cer_pac => admincfdi/cer_pac}/00001000000203430011.cer (100%) rename {cer_pac => admincfdi/cer_pac}/00001000000203495276.cer (100%) rename {cer_pac => admincfdi/cer_pac}/00001000000203495475.cer (100%) rename {cer_pac => admincfdi/cer_pac}/00001000000203631919.cer (100%) rename {cer_pac => admincfdi/cer_pac}/00001000000300091673.cer (100%) rename {cer_pac => admincfdi/cer_pac}/00001000000300171291.cer (100%) rename {cer_pac => admincfdi/cer_pac}/00001000000300171326.cer (100%) rename {cer_pac => admincfdi/cer_pac}/00001000000300209963.cer (100%) rename {cer_pac => admincfdi/cer_pac}/00001000000300250292.cer (100%) rename {cer_pac => admincfdi/cer_pac}/00001000000300392385.cer (100%) rename {cer_pac => admincfdi/cer_pac}/00001000000300407877.cer (100%) rename {cer_pac => admincfdi/cer_pac}/00001000000300439968.cer (100%) rename {cer_pac => admincfdi/cer_pac}/00001000000300494998.cer (100%) rename {cer_pac => admincfdi/cer_pac}/00001000000300627194.cer (100%) rename {cer_pac => admincfdi/cer_pac}/00001000000300716418.cer (100%) rename {cer_pac => admincfdi/cer_pac}/00001000000300716428.cer (100%) rename {cer_pac => admincfdi/cer_pac}/00001000000300774022.cer (100%) rename {cer_pac => admincfdi/cer_pac}/00001000000300915978.cer (100%) rename {cer_pac => admincfdi/cer_pac}/00001000000300969660.cer (100%) rename {cer_pac => admincfdi/cer_pac}/00001000000301021501.cer (100%) rename {cer_pac => admincfdi/cer_pac}/00001000000301032322.cer (100%) rename {cer_pac => admincfdi/cer_pac}/00001000000301062628.cer (100%) rename {cer_pac => admincfdi/cer_pac}/00001000000301083052.cer (100%) rename {cer_pac => admincfdi/cer_pac}/00001000000301100488.cer (100%) rename {cer_pac => admincfdi/cer_pac}/00001000000301160463.cer (100%) rename {cer_pac => admincfdi/cer_pac}/00001000000301280594.cer (100%) rename {cer_pac => admincfdi/cer_pac}/00001000000301567711.cer (100%) rename {cer_pac => admincfdi/cer_pac}/00001000000301634628.cer (100%) rename {cer_pac => admincfdi/cer_pac}/00001000000301751173.cer (100%) rename {cer_pac => admincfdi/cer_pac}/00001000000301914249.cer (100%) rename {cer_pac => admincfdi/cer_pac}/00001000000301927035.cer (100%) rename {cer_pac => admincfdi/cer_pac}/00001000000301949314.cer (100%) rename {cer_pac => admincfdi/cer_pac}/00001000000304339685.cer (100%) rename {cer_pac => admincfdi/cer_pac}/00001000000304691381.cer (100%) rename {cer_pac => admincfdi/cer_pac}/20001000000100005868.cer (100%) diff --git a/bin/cadena3.2.xslt b/admincfdi/bin/cadena3.2.xslt similarity index 100% rename from bin/cadena3.2.xslt rename to admincfdi/bin/cadena3.2.xslt diff --git a/bin/cfdi_3.2.xsd b/admincfdi/bin/cfdi_3.2.xsd similarity index 100% rename from bin/cfdi_3.2.xsd rename to admincfdi/bin/cfdi_3.2.xsd diff --git a/bin/cfdi_3.2.xslt b/admincfdi/bin/cfdi_3.2.xslt similarity index 100% rename from bin/cfdi_3.2.xslt rename to admincfdi/bin/cfdi_3.2.xslt diff --git a/bin/get_certificado.xslt b/admincfdi/bin/get_certificado.xslt similarity index 100% rename from bin/get_certificado.xslt rename to admincfdi/bin/get_certificado.xslt diff --git a/bin/get_sello.xslt b/admincfdi/bin/get_sello.xslt similarity index 100% rename from bin/get_sello.xslt rename to admincfdi/bin/get_sello.xslt diff --git a/bin/get_sello_sat.xslt b/admincfdi/bin/get_sello_sat.xslt similarity index 100% rename from bin/get_sello_sat.xslt rename to admincfdi/bin/get_sello_sat.xslt diff --git a/bin/iconv.dll b/admincfdi/bin/iconv.dll similarity index 100% rename from bin/iconv.dll rename to admincfdi/bin/iconv.dll diff --git a/bin/libeay32.dll b/admincfdi/bin/libeay32.dll similarity index 100% rename from bin/libeay32.dll rename to admincfdi/bin/libeay32.dll diff --git a/bin/libexslt.dll b/admincfdi/bin/libexslt.dll similarity index 100% rename from bin/libexslt.dll rename to admincfdi/bin/libexslt.dll diff --git a/bin/libxml2.dll b/admincfdi/bin/libxml2.dll similarity index 100% rename from bin/libxml2.dll rename to admincfdi/bin/libxml2.dll diff --git a/bin/libxslt.dll b/admincfdi/bin/libxslt.dll similarity index 100% rename from bin/libxslt.dll rename to admincfdi/bin/libxslt.dll diff --git a/bin/openssl.exe b/admincfdi/bin/openssl.exe similarity index 100% rename from bin/openssl.exe rename to admincfdi/bin/openssl.exe diff --git a/bin/ssleay32.dll b/admincfdi/bin/ssleay32.dll similarity index 100% rename from bin/ssleay32.dll rename to admincfdi/bin/ssleay32.dll diff --git a/bin/timbre.xslt b/admincfdi/bin/timbre.xslt similarity index 100% rename from bin/timbre.xslt rename to admincfdi/bin/timbre.xslt diff --git a/bin/timbre_1.0.xslt b/admincfdi/bin/timbre_1.0.xslt similarity index 100% rename from bin/timbre_1.0.xslt rename to admincfdi/bin/timbre_1.0.xslt diff --git a/bin/xsltproc.exe b/admincfdi/bin/xsltproc.exe similarity index 100% rename from bin/xsltproc.exe rename to admincfdi/bin/xsltproc.exe diff --git a/bin/zlib1.dll b/admincfdi/bin/zlib1.dll similarity index 100% rename from bin/zlib1.dll rename to admincfdi/bin/zlib1.dll diff --git a/cer_pac/00001000000103834451.cer b/admincfdi/cer_pac/00001000000103834451.cer similarity index 100% rename from cer_pac/00001000000103834451.cer rename to admincfdi/cer_pac/00001000000103834451.cer diff --git a/cer_pac/00001000000104731997.cer b/admincfdi/cer_pac/00001000000104731997.cer similarity index 100% rename from cer_pac/00001000000104731997.cer rename to admincfdi/cer_pac/00001000000104731997.cer diff --git a/cer_pac/00001000000104750010.cer b/admincfdi/cer_pac/00001000000104750010.cer similarity index 100% rename from cer_pac/00001000000104750010.cer rename to admincfdi/cer_pac/00001000000104750010.cer diff --git a/cer_pac/00001000000104871381.cer b/admincfdi/cer_pac/00001000000104871381.cer similarity index 100% rename from cer_pac/00001000000104871381.cer rename to admincfdi/cer_pac/00001000000104871381.cer diff --git a/cer_pac/00001000000104888042.cer b/admincfdi/cer_pac/00001000000104888042.cer similarity index 100% rename from cer_pac/00001000000104888042.cer rename to admincfdi/cer_pac/00001000000104888042.cer diff --git a/cer_pac/00001000000200005634.cer b/admincfdi/cer_pac/00001000000200005634.cer similarity index 100% rename from cer_pac/00001000000200005634.cer rename to admincfdi/cer_pac/00001000000200005634.cer diff --git a/cer_pac/00001000000200011997.cer b/admincfdi/cer_pac/00001000000200011997.cer similarity index 100% rename from cer_pac/00001000000200011997.cer rename to admincfdi/cer_pac/00001000000200011997.cer diff --git a/cer_pac/00001000000200365214.cer b/admincfdi/cer_pac/00001000000200365214.cer similarity index 100% rename from cer_pac/00001000000200365214.cer rename to admincfdi/cer_pac/00001000000200365214.cer diff --git a/cer_pac/00001000000200795916.cer b/admincfdi/cer_pac/00001000000200795916.cer similarity index 100% rename from cer_pac/00001000000200795916.cer rename to admincfdi/cer_pac/00001000000200795916.cer diff --git a/cer_pac/00001000000201345662.cer b/admincfdi/cer_pac/00001000000201345662.cer similarity index 100% rename from cer_pac/00001000000201345662.cer rename to admincfdi/cer_pac/00001000000201345662.cer diff --git a/cer_pac/00001000000201345708.cer b/admincfdi/cer_pac/00001000000201345708.cer similarity index 100% rename from cer_pac/00001000000201345708.cer rename to admincfdi/cer_pac/00001000000201345708.cer diff --git a/cer_pac/00001000000201395217.cer b/admincfdi/cer_pac/00001000000201395217.cer similarity index 100% rename from cer_pac/00001000000201395217.cer rename to admincfdi/cer_pac/00001000000201395217.cer diff --git a/cer_pac/00001000000201455572.cer b/admincfdi/cer_pac/00001000000201455572.cer similarity index 100% rename from cer_pac/00001000000201455572.cer rename to admincfdi/cer_pac/00001000000201455572.cer diff --git a/cer_pac/00001000000201614141.cer b/admincfdi/cer_pac/00001000000201614141.cer similarity index 100% rename from cer_pac/00001000000201614141.cer rename to admincfdi/cer_pac/00001000000201614141.cer diff --git a/cer_pac/00001000000201629292.cer b/admincfdi/cer_pac/00001000000201629292.cer similarity index 100% rename from cer_pac/00001000000201629292.cer rename to admincfdi/cer_pac/00001000000201629292.cer diff --git a/cer_pac/00001000000201748120.cer b/admincfdi/cer_pac/00001000000201748120.cer similarity index 100% rename from cer_pac/00001000000201748120.cer rename to admincfdi/cer_pac/00001000000201748120.cer diff --git a/cer_pac/00001000000202241710.cer b/admincfdi/cer_pac/00001000000202241710.cer similarity index 100% rename from cer_pac/00001000000202241710.cer rename to admincfdi/cer_pac/00001000000202241710.cer diff --git a/cer_pac/00001000000202453260.cer b/admincfdi/cer_pac/00001000000202453260.cer similarity index 100% rename from cer_pac/00001000000202453260.cer rename to admincfdi/cer_pac/00001000000202453260.cer diff --git a/cer_pac/00001000000202638162.cer b/admincfdi/cer_pac/00001000000202638162.cer similarity index 100% rename from cer_pac/00001000000202638162.cer rename to admincfdi/cer_pac/00001000000202638162.cer diff --git a/cer_pac/00001000000202639096.cer b/admincfdi/cer_pac/00001000000202639096.cer similarity index 100% rename from cer_pac/00001000000202639096.cer rename to admincfdi/cer_pac/00001000000202639096.cer diff --git a/cer_pac/00001000000202639521.cer b/admincfdi/cer_pac/00001000000202639521.cer similarity index 100% rename from cer_pac/00001000000202639521.cer rename to admincfdi/cer_pac/00001000000202639521.cer diff --git a/cer_pac/00001000000202693892.cer b/admincfdi/cer_pac/00001000000202693892.cer similarity index 100% rename from cer_pac/00001000000202693892.cer rename to admincfdi/cer_pac/00001000000202693892.cer diff --git a/cer_pac/00001000000202695775.cer b/admincfdi/cer_pac/00001000000202695775.cer similarity index 100% rename from cer_pac/00001000000202695775.cer rename to admincfdi/cer_pac/00001000000202695775.cer diff --git a/cer_pac/00001000000202700691.cer b/admincfdi/cer_pac/00001000000202700691.cer similarity index 100% rename from cer_pac/00001000000202700691.cer rename to admincfdi/cer_pac/00001000000202700691.cer diff --git a/cer_pac/00001000000202771790.cer b/admincfdi/cer_pac/00001000000202771790.cer similarity index 100% rename from cer_pac/00001000000202771790.cer rename to admincfdi/cer_pac/00001000000202771790.cer diff --git a/cer_pac/00001000000202772539.cer b/admincfdi/cer_pac/00001000000202772539.cer similarity index 100% rename from cer_pac/00001000000202772539.cer rename to admincfdi/cer_pac/00001000000202772539.cer diff --git a/cer_pac/00001000000202809550.cer b/admincfdi/cer_pac/00001000000202809550.cer similarity index 100% rename from cer_pac/00001000000202809550.cer rename to admincfdi/cer_pac/00001000000202809550.cer diff --git a/cer_pac/00001000000202864285.cer b/admincfdi/cer_pac/00001000000202864285.cer similarity index 100% rename from cer_pac/00001000000202864285.cer rename to admincfdi/cer_pac/00001000000202864285.cer diff --git a/cer_pac/00001000000202864530.cer b/admincfdi/cer_pac/00001000000202864530.cer similarity index 100% rename from cer_pac/00001000000202864530.cer rename to admincfdi/cer_pac/00001000000202864530.cer diff --git a/cer_pac/00001000000202864883.cer b/admincfdi/cer_pac/00001000000202864883.cer similarity index 100% rename from cer_pac/00001000000202864883.cer rename to admincfdi/cer_pac/00001000000202864883.cer diff --git a/cer_pac/00001000000202865018.cer b/admincfdi/cer_pac/00001000000202865018.cer similarity index 100% rename from cer_pac/00001000000202865018.cer rename to admincfdi/cer_pac/00001000000202865018.cer diff --git a/cer_pac/00001000000202905407.cer b/admincfdi/cer_pac/00001000000202905407.cer similarity index 100% rename from cer_pac/00001000000202905407.cer rename to admincfdi/cer_pac/00001000000202905407.cer diff --git a/cer_pac/00001000000203015571.cer b/admincfdi/cer_pac/00001000000203015571.cer similarity index 100% rename from cer_pac/00001000000203015571.cer rename to admincfdi/cer_pac/00001000000203015571.cer diff --git a/cer_pac/00001000000203051706.cer b/admincfdi/cer_pac/00001000000203051706.cer similarity index 100% rename from cer_pac/00001000000203051706.cer rename to admincfdi/cer_pac/00001000000203051706.cer diff --git a/cer_pac/00001000000203082087.cer b/admincfdi/cer_pac/00001000000203082087.cer similarity index 100% rename from cer_pac/00001000000203082087.cer rename to admincfdi/cer_pac/00001000000203082087.cer diff --git a/cer_pac/00001000000203092957.cer b/admincfdi/cer_pac/00001000000203092957.cer similarity index 100% rename from cer_pac/00001000000203092957.cer rename to admincfdi/cer_pac/00001000000203092957.cer diff --git a/cer_pac/00001000000203093174.cer b/admincfdi/cer_pac/00001000000203093174.cer similarity index 100% rename from cer_pac/00001000000203093174.cer rename to admincfdi/cer_pac/00001000000203093174.cer diff --git a/cer_pac/00001000000203159220.cer b/admincfdi/cer_pac/00001000000203159220.cer similarity index 100% rename from cer_pac/00001000000203159220.cer rename to admincfdi/cer_pac/00001000000203159220.cer diff --git a/cer_pac/00001000000203159375.cer b/admincfdi/cer_pac/00001000000203159375.cer similarity index 100% rename from cer_pac/00001000000203159375.cer rename to admincfdi/cer_pac/00001000000203159375.cer diff --git a/cer_pac/00001000000203191015.cer b/admincfdi/cer_pac/00001000000203191015.cer similarity index 100% rename from cer_pac/00001000000203191015.cer rename to admincfdi/cer_pac/00001000000203191015.cer diff --git a/cer_pac/00001000000203220518.cer b/admincfdi/cer_pac/00001000000203220518.cer similarity index 100% rename from cer_pac/00001000000203220518.cer rename to admincfdi/cer_pac/00001000000203220518.cer diff --git a/cer_pac/00001000000203220546.cer b/admincfdi/cer_pac/00001000000203220546.cer similarity index 100% rename from cer_pac/00001000000203220546.cer rename to admincfdi/cer_pac/00001000000203220546.cer diff --git a/cer_pac/00001000000203253077.cer b/admincfdi/cer_pac/00001000000203253077.cer similarity index 100% rename from cer_pac/00001000000203253077.cer rename to admincfdi/cer_pac/00001000000203253077.cer diff --git a/cer_pac/00001000000203285726.cer b/admincfdi/cer_pac/00001000000203285726.cer similarity index 100% rename from cer_pac/00001000000203285726.cer rename to admincfdi/cer_pac/00001000000203285726.cer diff --git a/cer_pac/00001000000203285735.cer b/admincfdi/cer_pac/00001000000203285735.cer similarity index 100% rename from cer_pac/00001000000203285735.cer rename to admincfdi/cer_pac/00001000000203285735.cer diff --git a/cer_pac/00001000000203292609.cer b/admincfdi/cer_pac/00001000000203292609.cer similarity index 100% rename from cer_pac/00001000000203292609.cer rename to admincfdi/cer_pac/00001000000203292609.cer diff --git a/cer_pac/00001000000203312933.cer b/admincfdi/cer_pac/00001000000203312933.cer similarity index 100% rename from cer_pac/00001000000203312933.cer rename to admincfdi/cer_pac/00001000000203312933.cer diff --git a/cer_pac/00001000000203352843.cer b/admincfdi/cer_pac/00001000000203352843.cer similarity index 100% rename from cer_pac/00001000000203352843.cer rename to admincfdi/cer_pac/00001000000203352843.cer diff --git a/cer_pac/00001000000203392777.cer b/admincfdi/cer_pac/00001000000203392777.cer similarity index 100% rename from cer_pac/00001000000203392777.cer rename to admincfdi/cer_pac/00001000000203392777.cer diff --git a/cer_pac/00001000000203430011.cer b/admincfdi/cer_pac/00001000000203430011.cer similarity index 100% rename from cer_pac/00001000000203430011.cer rename to admincfdi/cer_pac/00001000000203430011.cer diff --git a/cer_pac/00001000000203495276.cer b/admincfdi/cer_pac/00001000000203495276.cer similarity index 100% rename from cer_pac/00001000000203495276.cer rename to admincfdi/cer_pac/00001000000203495276.cer diff --git a/cer_pac/00001000000203495475.cer b/admincfdi/cer_pac/00001000000203495475.cer similarity index 100% rename from cer_pac/00001000000203495475.cer rename to admincfdi/cer_pac/00001000000203495475.cer diff --git a/cer_pac/00001000000203631919.cer b/admincfdi/cer_pac/00001000000203631919.cer similarity index 100% rename from cer_pac/00001000000203631919.cer rename to admincfdi/cer_pac/00001000000203631919.cer diff --git a/cer_pac/00001000000300091673.cer b/admincfdi/cer_pac/00001000000300091673.cer similarity index 100% rename from cer_pac/00001000000300091673.cer rename to admincfdi/cer_pac/00001000000300091673.cer diff --git a/cer_pac/00001000000300171291.cer b/admincfdi/cer_pac/00001000000300171291.cer similarity index 100% rename from cer_pac/00001000000300171291.cer rename to admincfdi/cer_pac/00001000000300171291.cer diff --git a/cer_pac/00001000000300171326.cer b/admincfdi/cer_pac/00001000000300171326.cer similarity index 100% rename from cer_pac/00001000000300171326.cer rename to admincfdi/cer_pac/00001000000300171326.cer diff --git a/cer_pac/00001000000300209963.cer b/admincfdi/cer_pac/00001000000300209963.cer similarity index 100% rename from cer_pac/00001000000300209963.cer rename to admincfdi/cer_pac/00001000000300209963.cer diff --git a/cer_pac/00001000000300250292.cer b/admincfdi/cer_pac/00001000000300250292.cer similarity index 100% rename from cer_pac/00001000000300250292.cer rename to admincfdi/cer_pac/00001000000300250292.cer diff --git a/cer_pac/00001000000300392385.cer b/admincfdi/cer_pac/00001000000300392385.cer similarity index 100% rename from cer_pac/00001000000300392385.cer rename to admincfdi/cer_pac/00001000000300392385.cer diff --git a/cer_pac/00001000000300407877.cer b/admincfdi/cer_pac/00001000000300407877.cer similarity index 100% rename from cer_pac/00001000000300407877.cer rename to admincfdi/cer_pac/00001000000300407877.cer diff --git a/cer_pac/00001000000300439968.cer b/admincfdi/cer_pac/00001000000300439968.cer similarity index 100% rename from cer_pac/00001000000300439968.cer rename to admincfdi/cer_pac/00001000000300439968.cer diff --git a/cer_pac/00001000000300494998.cer b/admincfdi/cer_pac/00001000000300494998.cer similarity index 100% rename from cer_pac/00001000000300494998.cer rename to admincfdi/cer_pac/00001000000300494998.cer diff --git a/cer_pac/00001000000300627194.cer b/admincfdi/cer_pac/00001000000300627194.cer similarity index 100% rename from cer_pac/00001000000300627194.cer rename to admincfdi/cer_pac/00001000000300627194.cer diff --git a/cer_pac/00001000000300716418.cer b/admincfdi/cer_pac/00001000000300716418.cer similarity index 100% rename from cer_pac/00001000000300716418.cer rename to admincfdi/cer_pac/00001000000300716418.cer diff --git a/cer_pac/00001000000300716428.cer b/admincfdi/cer_pac/00001000000300716428.cer similarity index 100% rename from cer_pac/00001000000300716428.cer rename to admincfdi/cer_pac/00001000000300716428.cer diff --git a/cer_pac/00001000000300774022.cer b/admincfdi/cer_pac/00001000000300774022.cer similarity index 100% rename from cer_pac/00001000000300774022.cer rename to admincfdi/cer_pac/00001000000300774022.cer diff --git a/cer_pac/00001000000300915978.cer b/admincfdi/cer_pac/00001000000300915978.cer similarity index 100% rename from cer_pac/00001000000300915978.cer rename to admincfdi/cer_pac/00001000000300915978.cer diff --git a/cer_pac/00001000000300969660.cer b/admincfdi/cer_pac/00001000000300969660.cer similarity index 100% rename from cer_pac/00001000000300969660.cer rename to admincfdi/cer_pac/00001000000300969660.cer diff --git a/cer_pac/00001000000301021501.cer b/admincfdi/cer_pac/00001000000301021501.cer similarity index 100% rename from cer_pac/00001000000301021501.cer rename to admincfdi/cer_pac/00001000000301021501.cer diff --git a/cer_pac/00001000000301032322.cer b/admincfdi/cer_pac/00001000000301032322.cer similarity index 100% rename from cer_pac/00001000000301032322.cer rename to admincfdi/cer_pac/00001000000301032322.cer diff --git a/cer_pac/00001000000301062628.cer b/admincfdi/cer_pac/00001000000301062628.cer similarity index 100% rename from cer_pac/00001000000301062628.cer rename to admincfdi/cer_pac/00001000000301062628.cer diff --git a/cer_pac/00001000000301083052.cer b/admincfdi/cer_pac/00001000000301083052.cer similarity index 100% rename from cer_pac/00001000000301083052.cer rename to admincfdi/cer_pac/00001000000301083052.cer diff --git a/cer_pac/00001000000301100488.cer b/admincfdi/cer_pac/00001000000301100488.cer similarity index 100% rename from cer_pac/00001000000301100488.cer rename to admincfdi/cer_pac/00001000000301100488.cer diff --git a/cer_pac/00001000000301160463.cer b/admincfdi/cer_pac/00001000000301160463.cer similarity index 100% rename from cer_pac/00001000000301160463.cer rename to admincfdi/cer_pac/00001000000301160463.cer diff --git a/cer_pac/00001000000301280594.cer b/admincfdi/cer_pac/00001000000301280594.cer similarity index 100% rename from cer_pac/00001000000301280594.cer rename to admincfdi/cer_pac/00001000000301280594.cer diff --git a/cer_pac/00001000000301567711.cer b/admincfdi/cer_pac/00001000000301567711.cer similarity index 100% rename from cer_pac/00001000000301567711.cer rename to admincfdi/cer_pac/00001000000301567711.cer diff --git a/cer_pac/00001000000301634628.cer b/admincfdi/cer_pac/00001000000301634628.cer similarity index 100% rename from cer_pac/00001000000301634628.cer rename to admincfdi/cer_pac/00001000000301634628.cer diff --git a/cer_pac/00001000000301751173.cer b/admincfdi/cer_pac/00001000000301751173.cer similarity index 100% rename from cer_pac/00001000000301751173.cer rename to admincfdi/cer_pac/00001000000301751173.cer diff --git a/cer_pac/00001000000301914249.cer b/admincfdi/cer_pac/00001000000301914249.cer similarity index 100% rename from cer_pac/00001000000301914249.cer rename to admincfdi/cer_pac/00001000000301914249.cer diff --git a/cer_pac/00001000000301927035.cer b/admincfdi/cer_pac/00001000000301927035.cer similarity index 100% rename from cer_pac/00001000000301927035.cer rename to admincfdi/cer_pac/00001000000301927035.cer diff --git a/cer_pac/00001000000301949314.cer b/admincfdi/cer_pac/00001000000301949314.cer similarity index 100% rename from cer_pac/00001000000301949314.cer rename to admincfdi/cer_pac/00001000000301949314.cer diff --git a/cer_pac/00001000000304339685.cer b/admincfdi/cer_pac/00001000000304339685.cer similarity index 100% rename from cer_pac/00001000000304339685.cer rename to admincfdi/cer_pac/00001000000304339685.cer diff --git a/cer_pac/00001000000304691381.cer b/admincfdi/cer_pac/00001000000304691381.cer similarity index 100% rename from cer_pac/00001000000304691381.cer rename to admincfdi/cer_pac/00001000000304691381.cer diff --git a/cer_pac/20001000000100005868.cer b/admincfdi/cer_pac/20001000000100005868.cer similarity index 100% rename from cer_pac/20001000000100005868.cer rename to admincfdi/cer_pac/20001000000100005868.cer diff --git a/admincfdi/pyutil.py b/admincfdi/pyutil.py index da2dbb1..9742615 100644 --- a/admincfdi/pyutil.py +++ b/admincfdi/pyutil.py @@ -34,7 +34,7 @@ from tkinter.filedialog import askdirectory from tkinter.filedialog import askopenfilename from tkinter import messagebox -from pysimplesoap.client import SoapClient, SoapFault +from requests import Request, Session from selenium import webdriver from fpdf import FPDF from admincfdi.values import Global @@ -65,24 +65,44 @@ class SAT(object): _webservice = 'https://consultaqr.facturaelectronica.sat.gob.mx/' \ - 'consultacfdiservice.svc?wsdl' - - def __init__(self): + 'consultacfdiservice.svc' + _soap = """ + + + + + + ?re={emisor_rfc}&rr={receptor_rfc}&tt={total}&id={uuid} + + + + """ + + def __init__(self, log): + self.log = log self.error = '' self.msg = '' def get_estatus(self, data): + data = self._soap.format(**data).encode('utf-8') + headers = { + 'SOAPAction': '"http://tempuri.org/IConsultaCFDIService/Consulta"', + 'Content-length': len(data), + 'Content-type': 'text/xml; charset="UTF-8"' + } + s = Session() + req = Request('POST', self._webservice, data=data, headers=headers) + prepped = req.prepare() try: - args = '?re={emisor_rfc}&rr={receptor_rfc}&tt={total}&id={uuid}' - client = SoapClient(wsdl = self._webservice) - fac = args.format(**data) - res = client.Consulta(fac) - if 'ConsultaResult' in res: - self.msg = res['ConsultaResult']['Estado'] - return True - return False - except SoapFault as sf: - self.error = sf.faultstring + response = s.send(prepped, timeout=5) + tree = ET.fromstring(response.text) + self.msg = tree[0][0][0][1].text + return True + except Exception as e: + self.log.error(str(e)) return False @@ -680,7 +700,7 @@ def get_info_report(self, path, options, g): 'total': data['total'], 'uuid': data['UUID'] } - sat = SAT() + sat = SAT(g.LOG) if sat.get_estatus(data_sat): info.append(sat.msg) else: diff --git a/setup.py b/setup.py index 46df802..c090b07 100644 --- a/setup.py +++ b/setup.py @@ -12,7 +12,7 @@ author_email='correopublico@mauriciobaeza.org', url='https://facturalibre.net/servicios/', packages=find_packages(), - install_requires=['pypng', 'fpdf', 'pygubu', 'selenium', 'pyqrcode', 'pysimplesoap'], + install_requires=['pypng', 'fpdf', 'pygubu', 'selenium', 'pyqrcode', 'requests'], package_data = {'': ['img/*.png', 'img/*.gif', 'ui/*', 'template/*']}, scripts=['admin-cfdi','descarga-cfdi', 'cfdi2pdf']) From 19a1b1248c437045b8ad07661543040381b7e476 Mon Sep 17 00:00:00 2001 From: Mauricio Baeza Date: Thu, 16 Apr 2015 18:18:55 -0500 Subject: [PATCH 102/167] Se selecciona la hora y minuto para busquedas de facturas emitidas --- admincfdi/pyutil.py | 17 +++++++++++++++++ admincfdi/values.py | 5 ++++- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/admincfdi/pyutil.py b/admincfdi/pyutil.py index 9742615..f79737d 100644 --- a/admincfdi/pyutil.py +++ b/admincfdi/pyutil.py @@ -1816,6 +1816,23 @@ def search(self, facturas_emitidas=False, self.g.SAT['date_to_name']) browser.execute_script(arg) txt.send_keys(dates[1]) + + # Hay que seleccionar también la hora, minuto y segundo + self.util.sleep(3) + combos = ( + (self.g.SAT['second'], '59'), + (self.g.SAT['minute'], '59'), + (self.g.SAT['hour'], '23'), + ) + for control, value in combos: + combo = browser.find_element_by_id(control) + combo = browser.find_element_by_id( + self.g.SAT['combos'].format(combo.get_attribute('sb'))) + combo.click() + self.util.sleep(2) + link = browser.find_element_by_link_text(value) + link.click() + self.util.sleep(2) # Recibidas else: #~ combos = browser.find_elements_by_class_name( diff --git a/admincfdi/values.py b/admincfdi/values.py index d2ae521..fa3b349 100644 --- a/admincfdi/values.py +++ b/admincfdi/values.py @@ -167,12 +167,15 @@ class Global(object): 'year': 'DdlAnio', 'month': 'ctl00_MainContent_CldFecha_DdlMes', 'day': 'ctl00_MainContent_CldFecha_DdlDia', + 'hour': 'ctl00_MainContent_CldFechaFinal2_DdlHora', + 'minute': 'ctl00_MainContent_CldFechaFinal2_DdlMinuto', + 'second': 'ctl00_MainContent_CldFechaFinal2_DdlSegundo', 'submit': 'ctl00_MainContent_BtnBusqueda', 'download': 'BtnDescarga', 'emisor': 'ctl00_MainContent_TxtRfcReceptor', 'receptor': 'ctl00_MainContent_TxtRfcReceptor', 'uuid': 'ctl00_MainContent_TxtUUID', - 'combos': 'sbToggle', + 'combos': 'sbToggle_{}', 'found': 'No existen registros que cumplan con los criterios de', 'subtitle': 'subtitle', 'page_init': page_init, From 3617fac5d9e2794f7b4aa7a5aeeaf5f84deb7476 Mon Sep 17 00:00:00 2001 From: Mauricio Baeza Date: Wed, 29 Apr 2015 18:42:53 -0500 Subject: [PATCH 103/167] =?UTF-8?q?La=20validaci=C3=B3n=20ahora=20soporta?= =?UTF-8?q?=20el=20complemente=20terceros?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- admincfdi/bin/cfdi_3.2.xslt | 104 +++++++++++++++++++++ admincfdi/bin/get_certificado.xslt | 4 + admincfdi/bin/get_sello.xslt | 2 +- admincfdi/cer_pac/00001000000301205071.cer | Bin 0 -> 1198 bytes admincfdi/cer_pac/00001000000301251152.cer | Bin 0 -> 1198 bytes 5 files changed, 109 insertions(+), 1 deletion(-) create mode 100644 admincfdi/cer_pac/00001000000301205071.cer create mode 100644 admincfdi/cer_pac/00001000000301251152.cer diff --git a/admincfdi/bin/cfdi_3.2.xslt b/admincfdi/bin/cfdi_3.2.xslt index 5c6a0d4..89c26d1 100644 --- a/admincfdi/bin/cfdi_3.2.xslt +++ b/admincfdi/bin/cfdi_3.2.xslt @@ -406,6 +406,110 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ||| diff --git a/admincfdi/bin/get_certificado.xslt b/admincfdi/bin/get_certificado.xslt index e003d1b..2eb2f8a 100644 --- a/admincfdi/bin/get_certificado.xslt +++ b/admincfdi/bin/get_certificado.xslt @@ -51,6 +51,10 @@ + + + + diff --git a/admincfdi/bin/get_sello.xslt b/admincfdi/bin/get_sello.xslt index 65d0165..af45a03 100644 --- a/admincfdi/bin/get_sello.xslt +++ b/admincfdi/bin/get_sello.xslt @@ -4,7 +4,7 @@ - + diff --git a/admincfdi/cer_pac/00001000000301205071.cer b/admincfdi/cer_pac/00001000000301205071.cer new file mode 100644 index 0000000000000000000000000000000000000000..b97bfe6672a7d8a0709b9f99b1278ec7fa77d227 GIT binary patch literal 1198 zcmXqLVp(O-#5`#MGZP~dlZXKj7=j1`17ib2BLhV@hsjUS@GgQDSoD;mvspAw`)< zr6q|)nTdw_2D%`fTs&$-YJghB4YUf)RG>w{sp+LfnJI}WAX{@16+H9O@{4jIwi?PC z$iiLD$tWg}n4F(d>`w5r&cm;vfzOkAMrv$C)Mh3T~+> zsYQu7h9U++ASrepZs*d((u|VBD|7Rc4Mh!vL87|CJi(43mgWZL28Moq#)hT_#t7Fk z@u&r*78m5_6(=U;q*^IB=OmV2*Bps!(D=SnpJ#ALkRwpQPa!18( zG04*qre2do^<2cNS2a+E1b_^304V4yxI2cpnHw7z8yUL0n;Oa)$bgiw3X7shd3ZSo z1qB%x8p;|-gXFn|MFJe1-Gf|#>DJQFz}wZ))WX=*!k}?Ja>8X~WngaXWiV*$WNK_= zIFeN@R8||i{8o0j_M-|ToyBvw#6HeC>gVP&`-`+v^OEzC{k|{b#b*n>*qmBD|CVn% zOJF76>ql{7Src*sVgt|Dh=+5;tV}xi;{BAZbAGIqR+=Pgs~B*6*W&4)md{iTnR)3G zn?QZ)$y%;g!am{J7gxF^c+KXDSA07m?CHs?Ea%=cF|&o5F9v!5oUmkt85#exFc~ly za05Lg%g@5X%*1v8+4aC|$K1ro$Pnk8Gtbh4HFW=Xy_S<}+_q_Jrkl)22(!>w8uWL6 zgI?mp!Zhb?Uru>3B#OLdGv?A{vtSDI@wT(;mMpPL$dX%lHafTTw+EZM>}=uZul7zz z36+W4W9Ir%Q_yvjnaQT9hH3NuNVxNTl8V&VQRA*GGyLQ#5azL+^LA^(jhej``=$6! z>HpNsoUuV+>TXdJtMH2ZEwS^TrN=PuS@m`0@xzmS>(i$7S-uhF zSF3LR{a@yP%b~BvN4CkN+rRZroz1TE`1Q#*?sN5KF1m7Y`NvP`-uKx#x2V7U``bHR ix-u>{DMI;8>8bMO2X__T3Ed4`cXa#v?>`@g7Xbk0<$=Wj literal 0 HcmV?d00001 diff --git a/admincfdi/cer_pac/00001000000301251152.cer b/admincfdi/cer_pac/00001000000301251152.cer new file mode 100644 index 0000000000000000000000000000000000000000..d094d92d750d53e7455d3ad547da5e17c616f5bc GIT binary patch literal 1198 zcmXqLVp(O-#5`#MGZP~dlZXKj7=j1`17ib2BU3{|QzHXjHcqWJkGAi;jEtV@hsjUS@GgQDSoD;mvspAw`)< zr6q|)nTdw_2D%`fTs&$-YJghB4YUf)RG>w{sp+LfnJI}WAX{@16+H9O@{4jIwi?PC z$iiLD$tWg}n4F(d>`w5r&cm;vfzOkAMrv$C)Mh3T~+> zsYQu7h9U++ASrepZs*d((u|VBD|7Rc4Mh!vL87|CJi(43mgWZL28Moq#)hT_#t7Fk z@u&r*78m5_6(=U;q*^IB=OmV~9|aSa;38R{A6fGlJdRtOXzlBk}GSoNv~%8&q%K@I=~eFb;N5I1vU17jmYcXv}mIRhDxGFD+x6e$lc z=b)e<14Bbu18I;vx3EZnqqDP{Bhc3-hK6q8rluCgrj`bc^N|xSBP#=QV=se2V<%H% zBg4`3^Ep-Sc0MQ9=7uk4U23I!wyTAww9M6Z0_*V(*?nG9|91Ylr+Mf@hKhggJl-Fr z5AOx^?Mh`!SyaTn)_d`*!yf_!P23pvRy^vMw8l{`Xr;VK;7!-)fS}Op%9f9&w@>r1 z*pbG1I#5FX;cM+vX@RMS3^sa6&b3&TWuY!&G;@(T6EhJ(ZZ zbgr?o`EUiN)oM=;i$Je`*KJrXb+P+K9(!PHu`+e`nL{!XGRnrM&Farbr}q>GA7uDs z$iGKZO0?)pPf4|x_x(d_?;j|y?Q#lQcqlmS(LW*Wja$2eYJ2%#7~MVfnP+Fo{D{_` zt-Zm4e3~b3cGmF7onbxW6UQ^NL&;3#LZ;Tb@EEQ;*Vqq;-}HHI^@zn_&6yqb%gl>; zk^ Date: Fri, 1 May 2015 12:44:00 -0500 Subject: [PATCH 104/167] =?UTF-8?q?Se=20actualiza=20el=20inicio=20de=20des?= =?UTF-8?q?carga=20a=202014,=20el=20SAT=20solo=20tiene=20a=20partir=20de?= =?UTF-8?q?=20este=20a=C3=B1o?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- admincfdi/values.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/admincfdi/values.py b/admincfdi/values.py index fa3b349..564693a 100644 --- a/admincfdi/values.py +++ b/admincfdi/values.py @@ -64,7 +64,7 @@ class Global(object): PESO = ('mxn', 'mxp', 'm.n.', 'p', 'mn', 'pmx', 'mex') DOLAR = ('dólar', 'dólares', 'dolar', 'dolares', 'usd') ICON = os.path.join(PATHS['img'], 'favicon.png') - YEAR_INIT = 2011 + YEAR_INIT = 2014 FIELDS_REPORT = '{UUID}|{serie}|{folio}|{emisor_rfc}|{emisor_nombre}|' \ '{receptor_rfc}|{receptor_nombre}|{fecha}|{FechaTimbrado}|' \ '{tipoDeComprobante}|{Moneda}|{TipoCambio}|{subTotal}|' \ From 1c6fd28a469c3092789f70da0157f98af7e1f84e Mon Sep 17 00:00:00 2001 From: Mauricio Baeza Date: Sat, 2 May 2015 08:24:35 -0500 Subject: [PATCH 105/167] Se agregar soporte para descargar completa del mes para las facturas emitidas --- admin-cfdi | 28 +++++++-------- admincfdi/pyutil.py | 85 ++++++++++++++++++++++++++++++++++++--------- 2 files changed, 81 insertions(+), 32 deletions(-) diff --git a/admin-cfdi b/admin-cfdi index 340ae69..ecbc692 100644 --- a/admin-cfdi +++ b/admin-cfdi @@ -381,22 +381,23 @@ class Application(pygubu.TkApplication): ok, data = self._validate_download_sat() if not ok: return - descarga = DescargaSAT(status_callback=self.msg_user, - download_callback=self.progress) + descarga = DescargaSAT( + status_callback=self.msg_user, download_callback=self.progress) profile = descarga.get_firefox_profile(data['carpeta_destino']) try: descarga.connect(profile, rfc=data['rfc'], ciec=data['ciec']) - docs = descarga.search(facturas_emitidas=data['facturas_emitidas'], - type_search=1 * (data['uuid'] != ''), - uuid=data['uuid'], - rfc_emisor=data['rfc_emisor'], - año=data['año'], - mes=data['mes'], - día=data['día'], - mes_completo_por_día=data['mes_completo_por_día']) + docs = descarga.search( + facturas_emitidas=data['facturas_emitidas'], + type_search=1 * (data['uuid'] != ''), + uuid=data['uuid'], + rfc_emisor=data['rfc_emisor'], + año=data['año'], + mes=data['mes'], + día=data['día'], + mes_completo_por_día=data['mes_completo_por_día']) descarga.download(docs) except Exception as e: - print (e) + self.g.LOG.error("Descarga SAT: \n" + str(e)) finally: descarga.disconnect() return @@ -484,10 +485,7 @@ class Application(pygubu.TkApplication): self.util.msgbox(msg) return False, data sat_month = self._get('sat_month') - if sat_month: - search_day = '00' - else: - search_day = self._get('search_day') + search_day = self._get('search_day') user = self.users_sat[current_user] data = { 'facturas_emitidas': self._get('type_invoice'), diff --git a/admincfdi/pyutil.py b/admincfdi/pyutil.py index f79737d..e188ab4 100644 --- a/admincfdi/pyutil.py +++ b/admincfdi/pyutil.py @@ -451,10 +451,13 @@ def save_config(self, path, key, value): def now(self): return datetime.now() - def get_dates(self, year, month): - days = calendar.monthrange(year, month)[1] - d1 = '01/{:02d}/{}'.format(month, year) - d2 = '{}/{:02d}/{}'.format(days, month, year) + def get_dates(self, year, month, day=0): + if day: + d1 = d2 = '{:02d}/{:02d}/{}'.format(day, month, year) + else: + days = calendar.monthrange(year, month)[1] + d1 = '01/{:02d}/{}'.format(month, year) + d2 = '{}/{:02d}/{}'.format(days, month, year) return d1, d2 def get_days(self, year, month): @@ -1766,12 +1769,17 @@ def disconnect(self): self.status('Desconectado...') self.browser = None - def search(self, facturas_emitidas=False, - type_search=0, - rfc='', ciec='', - uuid='', rfc_emisor='', - año=None, mes=None, día=None, - mes_completo_por_día=False): + def search(self, + facturas_emitidas=False, + type_search=0, + rfc='', + ciec='', + uuid='', + rfc_emisor='', + año=None, + mes=None, + día=None, + mes_completo_por_día=False): 'Busca y regresa los resultados' if self.browser: @@ -1792,7 +1800,7 @@ def search(self, facturas_emitidas=False, # Descargar por fecha opt = browser.find_element_by_id(self.g.SAT['date']) opt.click() - self.util.sleep() + self.util.sleep(3) if rfc_emisor: if type_search == 1: txt = browser.find_element_by_id(self.g.SAT['receptor']) @@ -1803,7 +1811,11 @@ def search(self, facturas_emitidas=False, if facturas_emitidas == 1: year = int(año) month = int(mes) - dates = self.util.get_dates(year, month) + day = int(día) + if not mes_completo_por_día and day: + dates = self.util.get_dates(year, month, day) + else: + dates = self.util.get_dates(year, month) txt = browser.find_element_by_id(self.g.SAT['date_from']) arg = "document.getElementsByName('{}')[0]." \ "removeAttribute('disabled');".format( @@ -1833,6 +1845,8 @@ def search(self, facturas_emitidas=False, link = browser.find_element_by_link_text(value) link.click() self.util.sleep(2) + if mes_completo_por_día: + return self._download_sat_month_emitidas(dates[1], day) # Recibidas else: #~ combos = browser.find_elements_by_class_name( @@ -1876,9 +1890,8 @@ def search(self, facturas_emitidas=False, self.util.sleep() browser.find_element_by_id(self.g.SAT['submit']).click() - sec = 3 - if facturas_emitidas != 1 and día == '00': - sec = 15 + sec = 15 + #~ El mismo tiempo tanto para emitidas como recibidas self.util.sleep(sec) # Bug del SAT if facturas_emitidas != 1 and día != '00': @@ -1927,7 +1940,9 @@ def search(self, facturas_emitidas=False, def download(self, docs): 'Descarga los resultados' - + if docs is None: + self.status('No se encontraron documentos') + return if self.browser: t = len(docs) for i, v in enumerate(docs): @@ -1939,11 +1954,12 @@ def download(self, docs): self.browser.get(download) self.progress(0, t) self.util.sleep() + return def _download_sat_month(self, año, mes, browser): '''Descarga CFDIs del SAT a una carpeta local - Todos los CFDIs del mes selecionado''' + Todos los CFDIs recibidos del mes selecionado''' year = int(año) month = int(mes) @@ -1985,6 +2001,41 @@ def _download_sat_month(self, año, mes, browser): self.util.sleep() return + def _download_sat_month_emitidas(self, date_end, day_init): + '''Descarga CFDIs del SAT a una carpeta local + + Todos los CFDIs emitidos del mes selecionado + La interfaz en el SAT para facturas emitidas es diferente que para las + recibidas, es necesario buscar otros controles. + ''' + if day_init > 0: + day_init -= 1 + last_day = int(date_end.split('/')[0]) + for day in range(day_init, last_day): + current = '{:02d}'.format(day + 1) + date_end[2:] + print ("Día: ", current) + arg = "document.getElementsByName('{}')[0].removeAttribute(" \ + "'disabled');".format(self.g.SAT['date_from_name']) + self.browser.execute_script(arg) + arg = "document.getElementsByName('{}')[0].removeAttribute(" \ + "'disabled');".format(self.g.SAT['date_to_name']) + self.browser.execute_script(arg) + date_from = self.browser.find_element_by_id(self.g.SAT['date_from']) + date_to = self.browser.find_element_by_id(self.g.SAT['date_to']) + date_from.clear() + date_to.clear() + date_from.send_keys(current) + date_to.send_keys(current) + self.browser.find_element_by_id(self.g.SAT['submit']).click() + self.util.sleep(5) + docs = self.browser.find_elements_by_name(self.g.SAT['download']) + if docs: + print ("\tDocumentos: ", len(docs)) + else: + print ("\tDocumentos: 0") + self.download(docs) + return + class CSVPDF(FPDF): """ From 063983127216469a1304e4fdf493d4d300db9b19 Mon Sep 17 00:00:00 2001 From: Patricio Paez Date: Sat, 2 May 2015 05:42:15 -0500 Subject: [PATCH 106/167] Agregar prueba para facturas emitidas --- functional_DescargaSAT.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/functional_DescargaSAT.py b/functional_DescargaSAT.py index ebe6ac3..e4f3105 100755 --- a/functional_DescargaSAT.py +++ b/functional_DescargaSAT.py @@ -143,6 +143,29 @@ def no_op(*args): descarga.disconnect() self.assertEqual(expected, len(os.listdir(destino))) + def test_emitidas(self): + import os + import tempfile + from admincfdi.pyutil import DescargaSAT + + def no_op(*args): + pass + + seccion = self.config['emitidas'] + año = seccion['año'] + mes = seccion['mes'] + expected = int(seccion['expected']) + descarga = DescargaSAT(status_callback=no_op, + download_callback=no_op) + with tempfile.TemporaryDirectory() as tempdir: + destino = os.path.join(tempdir, 'cfdi-descarga') + profile = descarga.get_firefox_profile(destino) + descarga.connect(profile, rfc=self.rfc, ciec=self.ciec) + docs = descarga.search(año=año, mes=mes, día='00', + facturas_emitidas=1) + descarga.disconnect() + self.assertEqual(expected, len(docs)) + if __name__ == '__main__': unittest.main() From c7522d33fbb3d5676db8b3023b1bec92b4f27b1a Mon Sep 17 00:00:00 2001 From: Patricio Paez Date: Sat, 2 May 2015 09:54:51 -0500 Subject: [PATCH 107/167] =?UTF-8?q?Remover=20el=20par=C3=A1metro=20type=5F?= =?UTF-8?q?search=20de=20search()?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Utilizar uuid - Actualizar las aplicaciones admin-cfdi y descarga-cfdi - Actualizar los ejemplos en la documentación de la API - Actualizar pruebas unitarias y funcionales --- admin-cfdi | 5 ----- admincfdi/pyutil.py | 5 ++--- admincfdi/tests/test_pyutil.py | 3 +-- descarga-cfdi | 1 - docs/devel.rst | 2 -- functional_DescargaSAT.py | 5 +---- 6 files changed, 4 insertions(+), 17 deletions(-) diff --git a/admin-cfdi b/admin-cfdi index ecbc692..49344dd 100644 --- a/admin-cfdi +++ b/admin-cfdi @@ -388,7 +388,6 @@ class Application(pygubu.TkApplication): descarga.connect(profile, rfc=data['rfc'], ciec=data['ciec']) docs = descarga.search( facturas_emitidas=data['facturas_emitidas'], - type_search=1 * (data['uuid'] != ''), uuid=data['uuid'], rfc_emisor=data['rfc_emisor'], año=data['año'], @@ -432,9 +431,6 @@ class Application(pygubu.TkApplication): - type_invoice: La selección hecha en *Tipo de consulta*: 2 facturas recibidas, 1 facturas emitidas - - type_search: La selección hecha en *Tipo - de búsqueda*: 0 por fecha, - 1 por folio fiscal (UUID) - search_uuid: el valor llenado para *UUID*, es cadena vacía por omisión - search_rfc: el valor llenado en *RFC Emisor*, @@ -489,7 +485,6 @@ class Application(pygubu.TkApplication): user = self.users_sat[current_user] data = { 'facturas_emitidas': self._get('type_invoice'), - 'type_search': opt, 'rfc': user['user_sat'], 'ciec': user['password'], 'carpeta_destino': user['target_sat'], diff --git a/admincfdi/pyutil.py b/admincfdi/pyutil.py index e188ab4..e396342 100644 --- a/admincfdi/pyutil.py +++ b/admincfdi/pyutil.py @@ -1771,7 +1771,6 @@ def disconnect(self): def search(self, facturas_emitidas=False, - type_search=0, rfc='', ciec='', uuid='', @@ -1792,7 +1791,7 @@ def search(self, browser.get(page_query) self.util.sleep(3) self.status('Buscando...') - if type_search == 1: + if uuid: txt = browser.find_element_by_id(self.g.SAT['uuid']) txt.click() txt.send_keys(uuid) @@ -1802,7 +1801,7 @@ def search(self, opt.click() self.util.sleep(3) if rfc_emisor: - if type_search == 1: + if uuid: txt = browser.find_element_by_id(self.g.SAT['receptor']) else: txt = browser.find_element_by_id(self.g.SAT['emisor']) diff --git a/admincfdi/tests/test_pyutil.py b/admincfdi/tests/test_pyutil.py index f4897f0..4ae45e5 100644 --- a/admincfdi/tests/test_pyutil.py +++ b/admincfdi/tests/test_pyutil.py @@ -81,8 +81,7 @@ def test_search_uuid(self): profile = webdriver.FirefoxProfile() descarga = DescargaSAT(status_callback=self.status) descarga.browser = MagicMock() - results = descarga.search(type_search=1, uuid='uuid', - día='00') + results = descarga.search(uuid='uuid', día='00') self.assertEqual(0, len(results)) def test_search_facturas_emitidas(self): diff --git a/descarga-cfdi b/descarga-cfdi index 0282172..e8689ad 100755 --- a/descarga-cfdi +++ b/descarga-cfdi @@ -93,7 +93,6 @@ def main(): try: descarga.connect(profile, rfc=rfc, ciec=pwd) docs = descarga.search(facturas_emitidas= args.facturas_emitidas, - type_search=1 * (args.uuid != ''), uuid=args.uuid, rfc_emisor=args.rfc_emisor, año=args.año, diff --git a/docs/devel.rst b/docs/devel.rst index 4c3f6bf..5da4cad 100644 --- a/docs/devel.rst +++ b/docs/devel.rst @@ -157,7 +157,6 @@ utilizan los siguientes pasos: obtenida:: docs = descarga.search(facturas_emitidas=facturas_emitidas, - type_search=1 * (uuid != ''), uuid=uuid, rfc_emisor=rfc_emisor, año=año, @@ -187,7 +186,6 @@ que son parte del proyecto:: try: descarga.connect(profile, rfc=rfc, ciec=pwd) docs = descarga.search(facturas_emitidas= args.facturas_emitidas, - type_search=1 * (args.uuid != ''), uuid=args.uuid, rfc_emisor=args.rfc_emisor, año=args.año, diff --git a/functional_DescargaSAT.py b/functional_DescargaSAT.py index e4f3105..2dbc96c 100755 --- a/functional_DescargaSAT.py +++ b/functional_DescargaSAT.py @@ -40,7 +40,6 @@ def no_op(*args): profile = descarga.get_firefox_profile('destino') descarga.connect(profile, rfc=self.rfc, ciec=self.ciec) result = descarga.search(uuid=uuid, - type_search=1, día='00') descarga.disconnect() self.assertEqual(expected, len(result)) @@ -62,9 +61,7 @@ def no_op(*args): destino = os.path.join(tempdir, 'cfdi-descarga') profile = descarga.get_firefox_profile(destino) descarga.connect(profile, rfc=self.rfc, ciec=self.ciec) - docs = descarga.search(uuid=uuid, - type_search=1, - día='00') + docs = descarga.search(uuid=uuid, día='00') descarga.download(docs) descarga.disconnect() self.assertEqual(expected, len(os.listdir(destino))) From fd842d2805f057a7cdd9ed8dec6ce0520921d447 Mon Sep 17 00:00:00 2001 From: Patricio Paez Date: Sat, 2 May 2015 11:23:59 -0500 Subject: [PATCH 108/167] Estandarizar facturas_emitidas como booleano - Actualizar en search() - Actualizar las aplicaciones admin-cfdi y descarga-cfdi - Actualizar pruebas unitarias y funcionales --- admin-cfdi | 11 ++++++----- admincfdi/pyutil.py | 8 ++++---- admincfdi/tests/test_pyutil.py | 4 ++-- descarga-cfdi | 4 ++-- functional_DescargaSAT.py | 2 +- 5 files changed, 15 insertions(+), 14 deletions(-) diff --git a/admin-cfdi b/admin-cfdi index 49344dd..a720e22 100644 --- a/admin-cfdi +++ b/admin-cfdi @@ -422,15 +422,16 @@ class Application(pygubu.TkApplication): y se regresa (False, {}) Si las validaciones pasan, se construye un - diccionario ``data`` con estas llaves y valores: + diccionario ``data`` con estas llaves y valores + requeridos por la API de descarga: - user_sat: un diccionario con el RFC, la CIEC y la carpeta destino que se seleccionaron, con las llaves ``user_sat``, ``password`` y ``target_sat``. - - type_invoice: La selección hecha en - *Tipo de consulta*: 2 facturas recibidas, - 1 facturas emitidas + - facturas_emitidas: True si la selección hecha en + *Tipo de consulta* es 1 facturas emitidas, False + si la selección es 2 facturas recibidas - search_uuid: el valor llenado para *UUID*, es cadena vacía por omisión - search_rfc: el valor llenado en *RFC Emisor*, @@ -484,7 +485,7 @@ class Application(pygubu.TkApplication): search_day = self._get('search_day') user = self.users_sat[current_user] data = { - 'facturas_emitidas': self._get('type_invoice'), + 'facturas_emitidas': self._get('type_invoice') == 1, 'rfc': user['user_sat'], 'ciec': user['password'], 'carpeta_destino': user['target_sat'], diff --git a/admincfdi/pyutil.py b/admincfdi/pyutil.py index e396342..3ad22f2 100644 --- a/admincfdi/pyutil.py +++ b/admincfdi/pyutil.py @@ -1785,7 +1785,7 @@ def search(self, browser = self.browser page_query = self.g.SAT['page_receptor'] - if facturas_emitidas == 1: + if facturas_emitidas: page_query = self.g.SAT['page_emisor'] browser.get(page_query) @@ -1807,7 +1807,7 @@ def search(self, txt = browser.find_element_by_id(self.g.SAT['emisor']) txt.send_keys(rfc_emisor) # Emitidas - if facturas_emitidas == 1: + if facturas_emitidas: year = int(año) month = int(mes) day = int(día) @@ -1893,7 +1893,7 @@ def search(self, #~ El mismo tiempo tanto para emitidas como recibidas self.util.sleep(sec) # Bug del SAT - if facturas_emitidas != 1 and día != '00': + if not facturas_emitidas and día != '00': combo = browser.find_element_by_id(self.g.SAT['day']) sb = combo.get_attribute('sb') combo = browser.find_element_by_id( @@ -1915,7 +1915,7 @@ def search(self, self.util.sleep(2) browser.find_element_by_id(self.g.SAT['submit']).click() self.util.sleep(sec) - elif facturas_emitidas == 2 and mes_completo_por_día: + elif not facturas_emitidas and mes_completo_por_día: return self._download_sat_month(año, mes, browser) try: diff --git a/admincfdi/tests/test_pyutil.py b/admincfdi/tests/test_pyutil.py index 4ae45e5..26f8fac 100644 --- a/admincfdi/tests/test_pyutil.py +++ b/admincfdi/tests/test_pyutil.py @@ -92,7 +92,7 @@ def test_search_facturas_emitidas(self): profile = webdriver.FirefoxProfile() descarga = DescargaSAT(status_callback=self.status) descarga.browser = MagicMock() - results = descarga.search(facturas_emitidas=1, + results = descarga.search(facturas_emitidas=True, año=1, mes=1) self.assertEqual(0, len(results)) @@ -163,7 +163,7 @@ def test_search_mes_completo_por_día(self): descarga = DescargaSAT(status_callback=self.status) descarga.browser = MagicMock() descarga._download_sat_month = Mock(return_value=[]) - results = descarga.search(facturas_emitidas=2, día='00', + results = descarga.search(día='00', mes_completo_por_día=True) self.assertEqual(0, len(results)) diff --git a/descarga-cfdi b/descarga-cfdi index e8689ad..c783233 100755 --- a/descarga-cfdi +++ b/descarga-cfdi @@ -47,8 +47,8 @@ def process_command_line_arguments(): help = 'Descargar facturas emitidas. ' \ 'Por omisión se descargan facturas recibidas' parser.add_argument('--facturas-emitidas', - action='store_const', const=1, - help=help, default=2) + action='store_const', const=True, + help=help, default=False) help = 'UUID. Por omisión no se usa en la búsqueda. ' \ 'Esta opción tiene precedencia sobre las demás ' \ diff --git a/functional_DescargaSAT.py b/functional_DescargaSAT.py index 2dbc96c..e97e9c5 100755 --- a/functional_DescargaSAT.py +++ b/functional_DescargaSAT.py @@ -159,7 +159,7 @@ def no_op(*args): profile = descarga.get_firefox_profile(destino) descarga.connect(profile, rfc=self.rfc, ciec=self.ciec) docs = descarga.search(año=año, mes=mes, día='00', - facturas_emitidas=1) + facturas_emitidas=True) descarga.disconnect() self.assertEqual(expected, len(docs)) From 5395a16ca842e804454fa02a69a153c0315848c4 Mon Sep 17 00:00:00 2001 From: Patricio Paez Date: Sat, 2 May 2015 11:39:17 -0500 Subject: [PATCH 109/167] =?UTF-8?q?Remover=20dos=20par=C3=A1metros=20de=20?= =?UTF-8?q?search()?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - rfc y ciec se usan ya en connect() --- admincfdi/pyutil.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/admincfdi/pyutil.py b/admincfdi/pyutil.py index 3ad22f2..1741065 100644 --- a/admincfdi/pyutil.py +++ b/admincfdi/pyutil.py @@ -1771,8 +1771,6 @@ def disconnect(self): def search(self, facturas_emitidas=False, - rfc='', - ciec='', uuid='', rfc_emisor='', año=None, From 2b5fc5b7a2659e9859d80dadcf4bfaf52f26462c Mon Sep 17 00:00:00 2001 From: Patricio Paez Date: Sat, 9 May 2015 15:18:10 -0500 Subject: [PATCH 110/167] =?UTF-8?q?Cambiar=20el=20valor=20predeterminado?= =?UTF-8?q?=20de=20d=C3=ADa=20para=20search()?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Se requiere un valor que pueda convertirse a entero para buscar facturas emitidas --- admincfdi/pyutil.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/admincfdi/pyutil.py b/admincfdi/pyutil.py index 1741065..daa5fe1 100644 --- a/admincfdi/pyutil.py +++ b/admincfdi/pyutil.py @@ -1775,7 +1775,7 @@ def search(self, rfc_emisor='', año=None, mes=None, - día=None, + día='00', mes_completo_por_día=False): 'Busca y regresa los resultados' From ed8bb254535aef9cc58f18e119671bbe14db8592 Mon Sep 17 00:00:00 2001 From: Patricio Paez Date: Thu, 30 Apr 2015 06:57:14 -0500 Subject: [PATCH 111/167] =?UTF-8?q?Importar=20m=C3=B3dulos?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- admincfdi/pyutil.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/admincfdi/pyutil.py b/admincfdi/pyutil.py index daa5fe1..8531815 100644 --- a/admincfdi/pyutil.py +++ b/admincfdi/pyutil.py @@ -36,6 +36,9 @@ from tkinter import messagebox from requests import Request, Session from selenium import webdriver +from selenium.webdriver.common.by import By +from selenium.webdriver.support.ui import WebDriverWait +from selenium.webdriver.support import expected_conditions as EC from fpdf import FPDF from admincfdi.values import Global From e00301e02db0450b8c2fd6be1c61c445dbddb32e Mon Sep 17 00:00:00 2001 From: Patricio Paez Date: Sat, 2 May 2015 12:45:17 -0500 Subject: [PATCH 112/167] Usar explicit waits - connect() cubierto - search() empezado - Actualizar pruebas unitarias --- admincfdi/pyutil.py | 21 ++++++++++++++------- admincfdi/tests/test_pyutil.py | 4 ++++ 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/admincfdi/pyutil.py b/admincfdi/pyutil.py index 8531815..929a337 100644 --- a/admincfdi/pyutil.py +++ b/admincfdi/pyutil.py @@ -1754,7 +1754,12 @@ def connect(self, profile, rfc='', ciec=''): txt = browser.find_element_by_name(self.g.SAT['password']) txt.send_keys(ciec) txt.submit() - time.sleep(3) + wait = WebDriverWait(browser, 10) + wait.until(EC.title_contains('NetIQ Access')) + iframe = browser.find_element(By.ID, 'content') + browser.switch_to.frame(iframe) + wait.until(EC.text_to_be_present_in_element( + (By.CLASS_NAME, 'messagetext'), 'session has been authenticated')) self.status('Conectado...') def disconnect(self): @@ -1790,7 +1795,8 @@ def search(self, page_query = self.g.SAT['page_emisor'] browser.get(page_query) - self.util.sleep(3) + wait = WebDriverWait(browser, 10) + wait.until(EC.title_contains('Buscar CFDI')) self.status('Buscando...') if uuid: txt = browser.find_element_by_id(self.g.SAT['uuid']) @@ -1800,12 +1806,13 @@ def search(self, # Descargar por fecha opt = browser.find_element_by_id(self.g.SAT['date']) opt.click() - self.util.sleep(3) + if facturas_emitidas: + txt = wait.until(EC.element_to_be_clickable( + (By.ID, self.g.SAT['receptor']))) + else: + txt = wait.until(EC.element_to_be_clickable( + (By.ID, self.g.SAT['emisor']))) if rfc_emisor: - if uuid: - txt = browser.find_element_by_id(self.g.SAT['receptor']) - else: - txt = browser.find_element_by_id(self.g.SAT['emisor']) txt.send_keys(rfc_emisor) # Emitidas if facturas_emitidas: diff --git a/admincfdi/tests/test_pyutil.py b/admincfdi/tests/test_pyutil.py index 26f8fac..9f6f29a 100644 --- a/admincfdi/tests/test_pyutil.py +++ b/admincfdi/tests/test_pyutil.py @@ -13,6 +13,9 @@ def setUp(self): pyutil.webdriver = Mock() pyutil.webdriver.FirefoxProfile = webdriver.FirefoxProfile + self.WebDriverWait = pyutil.WebDriverWait + pyutil.WebDriverWait = Mock() + self.sleep = time.sleep time.sleep = Mock() @@ -26,6 +29,7 @@ def tearDown(self): from admincfdi import pyutil pyutil.webdriver = self.webdriver + pyutil.WebDriverWait = self.WebDriverWait time.sleep = self.sleep del pyutil.print From 57dbf14220a963a9fcfd3d41c876b191d63b98ee Mon Sep 17 00:00:00 2001 From: Patricio Paez Date: Sun, 3 May 2015 08:20:38 -0500 Subject: [PATCH 113/167] =?UTF-8?q?A=C3=B1o,=20mes=20y=20d=C3=ADa=20en=20f?= =?UTF-8?q?acturas=20recibidas?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- admincfdi/pyutil.py | 25 ++++++++++++++----------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/admincfdi/pyutil.py b/admincfdi/pyutil.py index 929a337..f8b0662 100644 --- a/admincfdi/pyutil.py +++ b/admincfdi/pyutil.py @@ -1859,20 +1859,22 @@ def search(self, #~ combos = browser.find_elements_by_class_name( #~ self.g.SAT['combos']) #~ combos[0].click() - combo = browser.find_element_by_id(self.g.SAT['year']) + combo = wait.until(EC.presence_of_element_located( + (By.ID, self.g.SAT['year']))) + sb = combo.get_attribute('sb') combo = browser.find_element_by_id( - 'sbToggle_{}'.format(combo.get_attribute('sb'))) + 'sbToggle_{}'.format(sb)) combo.click() - self.util.sleep(2) - link = browser.find_element_by_link_text(año) + link = wait.until(EC.element_to_be_clickable( + (By.LINK_TEXT, año))) link.click() - self.util.sleep(2) - combo = browser.find_element_by_id(self.g.SAT['month']) + combo = wait.until(EC.presence_of_element_located( + (By.ID, self.g.SAT['month']))) combo = browser.find_element_by_id( 'sbToggle_{}'.format(combo.get_attribute('sb'))) combo.click() - self.util.sleep(2) - link = browser.find_element_by_link_text(mes) + link = wait.until(EC.element_to_be_clickable( + (By.LINK_TEXT, mes))) link.click() self.util.sleep(2) if día != '00': @@ -1897,9 +1899,8 @@ def search(self, self.util.sleep() browser.find_element_by_id(self.g.SAT['submit']).click() - sec = 15 - #~ El mismo tiempo tanto para emitidas como recibidas - self.util.sleep(sec) + wait.until(EC.presence_of_element_located( + (By.ID, 'ctl00_MainContent_UpnlResultados'))) # Bug del SAT if not facturas_emitidas and día != '00': combo = browser.find_element_by_id(self.g.SAT['day']) @@ -1908,6 +1909,8 @@ def search(self, 'sbToggle_{}'.format(sb)) combo.click() self.util.sleep(2) + link = wait.until(EC.element_to_be_clickable( + (By.LINK_TEXT, día))) if mes == día: links = browser.find_elements_by_link_text(día) for l in links: From 3006991a37ae3a7c4ff2e5e5668d64496742e248 Mon Sep 17 00:00:00 2001 From: Patricio Paez Date: Sun, 3 May 2015 08:29:52 -0500 Subject: [PATCH 114/167] =?UTF-8?q?Esperar=20al=20elemento=20d=C3=ADa=20ac?= =?UTF-8?q?tualizado?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- admincfdi/pyutil.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/admincfdi/pyutil.py b/admincfdi/pyutil.py index f8b0662..9e2cb40 100644 --- a/admincfdi/pyutil.py +++ b/admincfdi/pyutil.py @@ -1878,8 +1878,8 @@ def search(self, link.click() self.util.sleep(2) if día != '00': - combo = browser.find_element_by_id(self.g.SAT['day']) - sb = combo.get_attribute('sb') + combo_day = browser.find_element_by_id(self.g.SAT['day']) + sb = combo_day.get_attribute('sb') combo = browser.find_element_by_id( 'sbToggle_{}'.format(sb)) combo.click() @@ -1903,6 +1903,7 @@ def search(self, (By.ID, 'ctl00_MainContent_UpnlResultados'))) # Bug del SAT if not facturas_emitidas and día != '00': + wait.until(EC.staleness_of(combo_day)) combo = browser.find_element_by_id(self.g.SAT['day']) sb = combo.get_attribute('sb') combo = browser.find_element_by_id( From 2fccb7c5289f621a376f5aa4ecb83cb6f6e3168a Mon Sep 17 00:00:00 2001 From: Patricio Paez Date: Sun, 3 May 2015 08:35:43 -0500 Subject: [PATCH 115/167] Esperar a los resultados MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Una vez hecha la actualización, se prueba si existe el elemento de resultados para saber si hay facturas --- admincfdi/pyutil.py | 22 +++++++--------------- 1 file changed, 7 insertions(+), 15 deletions(-) diff --git a/admincfdi/pyutil.py b/admincfdi/pyutil.py index 9e2cb40..2b4d744 100644 --- a/admincfdi/pyutil.py +++ b/admincfdi/pyutil.py @@ -1924,25 +1924,17 @@ def search(self, else: link = browser.find_element_by_link_text(día) link.click() - self.util.sleep(2) + link = wait.until(EC.element_to_be_clickable( + (By.LINK_TEXT, día))) browser.find_element_by_id(self.g.SAT['submit']).click() - self.util.sleep(sec) + wait.until(EC.presence_of_element_located( + (By.ID, 'ctl00_MainContent_UpnlResultados'))) elif not facturas_emitidas and mes_completo_por_día: return self._download_sat_month(año, mes, browser) - try: - found = True - content = browser.find_elements_by_class_name( - self.g.SAT['subtitle']) - for c in content: - if self.g.SAT['found'] in c.get_attribute('innerHTML') \ - and c.is_displayed(): - found = False - break - except Exception as e: - print (str(e)) - - if found: + results_table = wait.until(EC.presence_of_element_located( + (By.ID, 'ctl00_MainContent_PnlResultados'))) + if results_table.is_displayed(): docs = browser.find_elements_by_name(self.g.SAT['download']) return docs else: From 8c6bf83073bc02ffdf2decc06db75104d58de4a3 Mon Sep 17 00:00:00 2001 From: Patricio Paez Date: Sun, 3 May 2015 12:54:25 -0500 Subject: [PATCH 116/167] Esperar la lista de botones de descarga MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Asegurar que estén actualizados los botones y prevenir un error intermitente más adelante en download() --- admincfdi/pyutil.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/admincfdi/pyutil.py b/admincfdi/pyutil.py index 2b4d744..0b8f88e 100644 --- a/admincfdi/pyutil.py +++ b/admincfdi/pyutil.py @@ -1935,6 +1935,8 @@ def search(self, results_table = wait.until(EC.presence_of_element_located( (By.ID, 'ctl00_MainContent_PnlResultados'))) if results_table.is_displayed(): + wait.until(EC.element_to_be_clickable( + (By.NAME, self.g.SAT['download']))) docs = browser.find_elements_by_name(self.g.SAT['download']) return docs else: From d9b9ee56d1604d841b0d9d779d2abd75aa28ae65 Mon Sep 17 00:00:00 2001 From: Patricio Paez Date: Sun, 10 May 2015 06:09:11 -0500 Subject: [PATCH 117/167] Agregar una expected condition a Selenium MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Se tomó y adaptó el código de la función visibility_of() en selenium.webdriver.support.expected_conditions - Se tomó el código de la función auxiliar _element_if_visible() del mismo módulo. - Esta nueva expected condition es necesaria para la espera después de que se envía la forma de búsqueda. --- admincfdi/pyutil.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/admincfdi/pyutil.py b/admincfdi/pyutil.py index 0b8f88e..7834426 100644 --- a/admincfdi/pyutil.py +++ b/admincfdi/pyutil.py @@ -1688,6 +1688,26 @@ def _format_date(self, date_string): return date.strftime('{}, %d de {} de %Y'.format( d[date.weekday()], m[date.month])) +class visibility_of_either(object): + """ An expectation for checking that one of two elements, + located by locator 1 and locator2, is visible. + Visibility means that the element is not only + displayed but also has a height and width that is + greater than 0. + returns the WebElement that is visible. + """ + def __init__(self, locator1, locator2): + self.locator1 = locator1 + self.locator2 = locator2 + + def __call__(self, driver): + element1 = driver.find_element(*self.locator1) + element2 = driver.find_element(*self.locator2) + return _element_if_visible(element1) or \ + _element_if_visible(element2) + +def _element_if_visible(element): + return element if element.is_displayed() else False class DescargaSAT(object): From ab34fcdcb4be942cd5287fe1c1276e9bb340d740 Mon Sep 17 00:00:00 2001 From: Patricio Paez Date: Sun, 10 May 2015 06:22:58 -0500 Subject: [PATCH 118/167] Usar visibility_of_either() --- admincfdi/pyutil.py | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/admincfdi/pyutil.py b/admincfdi/pyutil.py index 7834426..180eddf 100644 --- a/admincfdi/pyutil.py +++ b/admincfdi/pyutil.py @@ -1919,8 +1919,9 @@ def search(self, self.util.sleep() browser.find_element_by_id(self.g.SAT['submit']).click() - wait.until(EC.presence_of_element_located( - (By.ID, 'ctl00_MainContent_UpnlResultados'))) + wait.until(visibility_of_either( + (By.ID, "ctl00_MainContent_PnlResultados"), + (By.ID, 'ctl00_MainContent_PnlNoResultados'))) # Bug del SAT if not facturas_emitidas and día != '00': wait.until(EC.staleness_of(combo_day)) @@ -1947,14 +1948,16 @@ def search(self, link = wait.until(EC.element_to_be_clickable( (By.LINK_TEXT, día))) browser.find_element_by_id(self.g.SAT['submit']).click() - wait.until(EC.presence_of_element_located( - (By.ID, 'ctl00_MainContent_UpnlResultados'))) + wait.until(visibility_of_either( + (By.ID, "ctl00_MainContent_PnlResultados"), + (By.ID, 'ctl00_MainContent_PnlNoResultados'))) elif not facturas_emitidas and mes_completo_por_día: return self._download_sat_month(año, mes, browser) - results_table = wait.until(EC.presence_of_element_located( - (By.ID, 'ctl00_MainContent_PnlResultados'))) - if results_table.is_displayed(): + results = wait.until(visibility_of_either( + (By.ID, "ctl00_MainContent_PnlResultados"), + (By.ID, 'ctl00_MainContent_PnlNoResultados'))) + if 'PnlResultados' in results.get_attribute('id'): wait.until(EC.element_to_be_clickable( (By.NAME, self.g.SAT['download']))) docs = browser.find_elements_by_name(self.g.SAT['download']) From 355ed2b3f4eac44603367859d87cfe4eaa6a077d Mon Sep 17 00:00:00 2001 From: Patricio Paez Date: Sun, 10 May 2015 07:40:30 -0500 Subject: [PATCH 119/167] Agregar espera para borrado MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - La tabla de resultados se descarta después de enviar la forma de búsqueda, antes de que esté disponible de nuevo --- admincfdi/pyutil.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/admincfdi/pyutil.py b/admincfdi/pyutil.py index 180eddf..7466a5d 100644 --- a/admincfdi/pyutil.py +++ b/admincfdi/pyutil.py @@ -1918,7 +1918,10 @@ def search(self, link.click() self.util.sleep() + results_table = browser.find_element_by_id( + 'ctl00_MainContent_PnlResultados') browser.find_element_by_id(self.g.SAT['submit']).click() + wait.until(EC.staleness_of(results_table)) wait.until(visibility_of_either( (By.ID, "ctl00_MainContent_PnlResultados"), (By.ID, 'ctl00_MainContent_PnlNoResultados'))) @@ -1947,7 +1950,10 @@ def search(self, link.click() link = wait.until(EC.element_to_be_clickable( (By.LINK_TEXT, día))) + results_table = browser.find_element_by_id( + 'ctl00_MainContent_PnlResultados') browser.find_element_by_id(self.g.SAT['submit']).click() + wait.until(EC.staleness_of(results_table)) wait.until(visibility_of_either( (By.ID, "ctl00_MainContent_PnlResultados"), (By.ID, 'ctl00_MainContent_PnlNoResultados'))) From d40b27137339dbbf0d1e0a52399ff0dd3dd2320e Mon Sep 17 00:00:00 2001 From: Patricio Paez Date: Sun, 10 May 2015 07:54:17 -0500 Subject: [PATCH 120/167] Usar el valor completo --- admincfdi/pyutil.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/admincfdi/pyutil.py b/admincfdi/pyutil.py index 7466a5d..98df7bf 100644 --- a/admincfdi/pyutil.py +++ b/admincfdi/pyutil.py @@ -1963,7 +1963,7 @@ def search(self, results = wait.until(visibility_of_either( (By.ID, "ctl00_MainContent_PnlResultados"), (By.ID, 'ctl00_MainContent_PnlNoResultados'))) - if 'PnlResultados' in results.get_attribute('id'): + if results.get_attribute('id') == 'ctl00_MainContent_PnlResultados': wait.until(EC.element_to_be_clickable( (By.NAME, self.g.SAT['download']))) docs = browser.find_elements_by_name(self.g.SAT['download']) From ab73c0994152ef72314443c0ee037ee9527c3902 Mon Sep 17 00:00:00 2001 From: Patricio Paez Date: Sun, 10 May 2015 08:52:45 -0500 Subject: [PATCH 121/167] Agregar y usar constantes en values.Global.SAT --- admincfdi/pyutil.py | 18 +++++++++--------- admincfdi/values.py | 2 ++ 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/admincfdi/pyutil.py b/admincfdi/pyutil.py index 98df7bf..84519cf 100644 --- a/admincfdi/pyutil.py +++ b/admincfdi/pyutil.py @@ -1919,12 +1919,12 @@ def search(self, self.util.sleep() results_table = browser.find_element_by_id( - 'ctl00_MainContent_PnlResultados') + self.g.SAT['resultados']) browser.find_element_by_id(self.g.SAT['submit']).click() wait.until(EC.staleness_of(results_table)) wait.until(visibility_of_either( - (By.ID, "ctl00_MainContent_PnlResultados"), - (By.ID, 'ctl00_MainContent_PnlNoResultados'))) + (By.ID, self.g.SAT['resultados']), + (By.ID, self.g.SAT['noresultados']))) # Bug del SAT if not facturas_emitidas and día != '00': wait.until(EC.staleness_of(combo_day)) @@ -1951,19 +1951,19 @@ def search(self, link = wait.until(EC.element_to_be_clickable( (By.LINK_TEXT, día))) results_table = browser.find_element_by_id( - 'ctl00_MainContent_PnlResultados') + self.g.SAT['resultados']) browser.find_element_by_id(self.g.SAT['submit']).click() wait.until(EC.staleness_of(results_table)) wait.until(visibility_of_either( - (By.ID, "ctl00_MainContent_PnlResultados"), - (By.ID, 'ctl00_MainContent_PnlNoResultados'))) + (By.ID, self.g.SAT['resultados']), + (By.ID, self.g.SAT['noresultados']))) elif not facturas_emitidas and mes_completo_por_día: return self._download_sat_month(año, mes, browser) results = wait.until(visibility_of_either( - (By.ID, "ctl00_MainContent_PnlResultados"), - (By.ID, 'ctl00_MainContent_PnlNoResultados'))) - if results.get_attribute('id') == 'ctl00_MainContent_PnlResultados': + (By.ID, self.g.SAT['resultados']), + (By.ID, self.g.SAT['noresultados']))) + if results.get_attribute('id') == self.g.SAT['resultados']: wait.until(EC.element_to_be_clickable( (By.NAME, self.g.SAT['download']))) docs = browser.find_elements_by_name(self.g.SAT['download']) diff --git a/admincfdi/values.py b/admincfdi/values.py index 564693a..1209f40 100644 --- a/admincfdi/values.py +++ b/admincfdi/values.py @@ -182,6 +182,8 @@ class Global(object): 'page_cfdi': page_cfdi, 'page_receptor': page_cfdi.format('ConsultaReceptor.aspx'), 'page_emisor': page_cfdi.format('ConsultaEmisor.aspx'), + 'resultados': 'ctl00_MainContent_PnlResultados', + 'noresultados': 'ctl00_MainContent_PnlNoResultados', } frm_1 = '%(asctime)s - %(levelname)s - %(lineno)s - %(message)s' CONF_LOG = { From 1b0eac7afcf26581ac2b110501b7a2b50d623319 Mon Sep 17 00:00:00 2001 From: Patricio Paez Date: Mon, 11 May 2015 23:08:42 -0500 Subject: [PATCH 122/167] Accesar los selects directamente --- admincfdi/pyutil.py | 87 +++++++-------------------------------------- 1 file changed, 13 insertions(+), 74 deletions(-) diff --git a/admincfdi/pyutil.py b/admincfdi/pyutil.py index 84519cf..ee89a00 100644 --- a/admincfdi/pyutil.py +++ b/admincfdi/pyutil.py @@ -1876,47 +1876,18 @@ def search(self, return self._download_sat_month_emitidas(dates[1], day) # Recibidas else: - #~ combos = browser.find_elements_by_class_name( - #~ self.g.SAT['combos']) - #~ combos[0].click() - combo = wait.until(EC.presence_of_element_located( - (By.ID, self.g.SAT['year']))) - sb = combo.get_attribute('sb') - combo = browser.find_element_by_id( - 'sbToggle_{}'.format(sb)) - combo.click() - link = wait.until(EC.element_to_be_clickable( - (By.LINK_TEXT, año))) - link.click() - combo = wait.until(EC.presence_of_element_located( - (By.ID, self.g.SAT['month']))) - combo = browser.find_element_by_id( - 'sbToggle_{}'.format(combo.get_attribute('sb'))) - combo.click() - link = wait.until(EC.element_to_be_clickable( - (By.LINK_TEXT, mes))) - link.click() - self.util.sleep(2) - if día != '00': - combo_day = browser.find_element_by_id(self.g.SAT['day']) - sb = combo_day.get_attribute('sb') - combo = browser.find_element_by_id( - 'sbToggle_{}'.format(sb)) - combo.click() - self.util.sleep() - if mes == día: - links = browser.find_elements_by_link_text(día) - for l in links: - p = l.find_element_by_xpath( - '..').find_element_by_xpath('..') - sb2 = p.get_attribute('id') - if sb in sb2: - link = l - break - else: - link = browser.find_element_by_link_text(día) - link.click() - self.util.sleep() + arg = "document.getElementById('{}')." \ + "value={};".format( + self.g.SAT['year'], año) + browser.execute_script(arg) + arg = "document.getElementById('{}')." \ + "value={};".format( + self.g.SAT['month'], mes) + browser.execute_script(arg) + arg = "document.getElementById('{}')." \ + "value='{}';".format( + self.g.SAT['day'], día) + browser.execute_script(arg) results_table = browser.find_element_by_id( self.g.SAT['resultados']) @@ -1925,39 +1896,7 @@ def search(self, wait.until(visibility_of_either( (By.ID, self.g.SAT['resultados']), (By.ID, self.g.SAT['noresultados']))) - # Bug del SAT - if not facturas_emitidas and día != '00': - wait.until(EC.staleness_of(combo_day)) - combo = browser.find_element_by_id(self.g.SAT['day']) - sb = combo.get_attribute('sb') - combo = browser.find_element_by_id( - 'sbToggle_{}'.format(sb)) - combo.click() - self.util.sleep(2) - link = wait.until(EC.element_to_be_clickable( - (By.LINK_TEXT, día))) - if mes == día: - links = browser.find_elements_by_link_text(día) - for l in links: - p = l.find_element_by_xpath( - '..').find_element_by_xpath('..') - sb2 = p.get_attribute('id') - if sb in sb2: - link = l - break - else: - link = browser.find_element_by_link_text(día) - link.click() - link = wait.until(EC.element_to_be_clickable( - (By.LINK_TEXT, día))) - results_table = browser.find_element_by_id( - self.g.SAT['resultados']) - browser.find_element_by_id(self.g.SAT['submit']).click() - wait.until(EC.staleness_of(results_table)) - wait.until(visibility_of_either( - (By.ID, self.g.SAT['resultados']), - (By.ID, self.g.SAT['noresultados']))) - elif not facturas_emitidas and mes_completo_por_día: + if not facturas_emitidas and mes_completo_por_día: return self._download_sat_month(año, mes, browser) results = wait.until(visibility_of_either( From 80473bdc35c9eb9d83ab8355ddc4ffdf497b19e6 Mon Sep 17 00:00:00 2001 From: Mauricio Baeza Date: Wed, 13 May 2015 13:51:22 -0500 Subject: [PATCH 123/167] Solucion para el iusse 14 --- admincfdi/pyutil.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/admincfdi/pyutil.py b/admincfdi/pyutil.py index ee89a00..11179f1 100644 --- a/admincfdi/pyutil.py +++ b/admincfdi/pyutil.py @@ -544,9 +544,7 @@ def get_name(self, path, PRE, format1='', format2=''): if not 'serie' in data: data['serie'] = '' data['fecha'] = data['fecha'].partition('T')[0] - if 'folio' in data: - data['folio'] = int(data['folio']) - else: + if not 'folio' in data: data['folio'] = 0 node = xml.find('{}Emisor'.format(pre)) data['emisor_rfc'] = node.attrib['rfc'] From a1774365e867a13d3654a0dba9c73f4d5175409b Mon Sep 17 00:00:00 2001 From: Patricio Paez Date: Wed, 13 May 2015 17:45:33 -0500 Subject: [PATCH 124/167] Separar las aplicaciones de la biblioteca --- docs/devel.rst | 12 +++++++----- docs/uso.rst | 11 +++++++++++ 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/docs/devel.rst b/docs/devel.rst index 5da4cad..f00e7f2 100644 --- a/docs/devel.rst +++ b/docs/devel.rst @@ -1,14 +1,16 @@ ========== Desarrollo ========== +Este capítulo contiene información útil para quienes desean +desarrollar aplicaciones que trabajen con CFDIs, para lo +cual pueden usar una o más de las clases disponibles dentro +del paquete `admincfdi`. + Estructura ========== -La aplicación consta de los siguientes archivos: - -- admincfdi.py Implementa la interfase gráfica y - es la aplicación principal. +El paquete `admincfdi` incluye los siguientes módulos: - values.py Tiene la clase Global que centraliza valores que se usan en los otros módulos. Por @@ -18,7 +20,7 @@ La aplicación consta de los siguientes archivos: en la descarga de CFDIs. - pyutil.py Tiene varias clases que implementan - utilerías usadas por los otros módulos. + utilerías usadas por las aplicaciones. Descarga de facturas del SAT ============================ diff --git a/docs/uso.rst b/docs/uso.rst index cee18e2..d0fa0dd 100644 --- a/docs/uso.rst +++ b/docs/uso.rst @@ -2,6 +2,17 @@ Uso === +Aplicaciones +------------ +Admincfdi incluye las siguientes aplicaciones: + +- `admin-cfdi` + +- `descarga-cfdi` + +- `cfdi2pdf` + + Pruebas funcionales de descarga del SAT --------------------------------------- From 15ec7800edd7121225ca377d72e2ee843c04c180 Mon Sep 17 00:00:00 2001 From: Patricio Paez Date: Wed, 13 May 2015 17:47:06 -0500 Subject: [PATCH 125/167] Actualizar los pasos de descarga --- docs/devel.rst | 33 +++++++++++++++++---------------- 1 file changed, 17 insertions(+), 16 deletions(-) diff --git a/docs/devel.rst b/docs/devel.rst index f00e7f2..8b04cda 100644 --- a/docs/devel.rst +++ b/docs/devel.rst @@ -47,30 +47,29 @@ estos dos métodos: El proceso de descarga consiste en estos pasos: -#. Lanzar el navegador - -#. Entrar a la página de búsquedas de CFDIs +#. Conectar a la aplicación de CFDIs del SAT + - Lanzar el navegador - Navegar a la página de login de CFDIs - - Llenar el usuario y la contraseña (RFC y CIEC) - - Enviar los datos al servidor + - Esperar la respuesta + +#. Realizar una búsqueda - Navegar a la página de búsqueda de facturas emitidas, o a la de facturas recibidas - -#. Solicitar la búsqueda - - Seleccionar el tipo de búsqueda - Llenar los datos de la búsqueda - Enviar los datos al servidor - -#. Descargar cada renglón de los resultados - + - Esperar los resultados - Encontrar los elementos con atributo ``name`` igual a *download*, corresponden al ícono de descarga a la izquierda en cada renglón. + - Regresar una lista de los valores + + +#. Descargar cada renglón de los resultados - Iterar en cada elemento de esta lista: @@ -79,12 +78,14 @@ El proceso de descarga consiste en estos pasos: del elemento - Hacer la solicitud GET a esta URL -#. Cerrar la sesión -#. Cerrar el navegador +#. Desconectar + - Cerrar la sesión + - Cerrar el navegador. Este paso se realiza + a pesar de que ocurra una falla en el paso + anterior. -En caso de alguna falla en los primeros cuatro pasos, -se intenta el 5, y por último y en todos los casos -se realiza el paso 6. +En caso de alguna falla en los primeros tres pasos, +la aplicación debe realizar el paso 4. El avance del proceso se indica al usuario mediante textos cortos que se muestran en una línea de estado From 5c105482ea728a3da03607432d14ec5ff221f785 Mon Sep 17 00:00:00 2001 From: Patricio Paez Date: Wed, 13 May 2015 17:53:28 -0500 Subject: [PATCH 126/167] Primero va pyutil --- docs/devel.rst | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/devel.rst b/docs/devel.rst index 8b04cda..53d995e 100644 --- a/docs/devel.rst +++ b/docs/devel.rst @@ -12,16 +12,16 @@ Estructura El paquete `admincfdi` incluye los siguientes módulos: -- values.py Tiene la clase Global que centraliza +- `pyutil` Tiene varias clases que implementan + las funcionalidades usadas por las aplicaciones. + +- `values` Tiene la clase Global que centraliza valores que se usan en los otros módulos. Por ejemplo, las URLs y valores id de la página web de CFDIs del SAT están en el atributo SAT, es un diccionario que es usado en la descarga de CFDIs. -- pyutil.py Tiene varias clases que implementan - utilerías usadas por las aplicaciones. - Descarga de facturas del SAT ============================ From 2d170258d442d9b077d1691bc2c06cf026aa0aa6 Mon Sep 17 00:00:00 2001 From: Patricio Paez Date: Wed, 13 May 2015 18:15:32 -0500 Subject: [PATCH 127/167] Separar admin-cfdi --- docs/devel.rst | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/docs/devel.rst b/docs/devel.rst index 53d995e..0f3d935 100644 --- a/docs/devel.rst +++ b/docs/devel.rst @@ -22,8 +22,8 @@ El paquete `admincfdi` incluye los siguientes módulos: es un diccionario que es usado en la descarga de CFDIs. -Descarga de facturas del SAT -============================ +admin-cfdi +========== La descarga de los archivos XML del sitio web del SAT se maneja en la primera pestaña de la interfase gráfica. @@ -37,13 +37,16 @@ datos y/o seleccionar opciones en estos tres apartados: El proceso de la descarga se inicia mediante el botón ``Descargar``, el cual está ligado al método -:func:`admincfdi.Application.button_download_sat_click` +:func:`admin-cfdi.Application.button_download_sat_click` de la aplicación, que ejecuta estos dos métodos: -- :func:`admincfdi.Application._validate_download_sat` +- :func:`admin-cfdi.Application._validate_download_sat` -- :func:`admincfdi.Application._download_sat` +- :func:`admin-cfdi.Application._download_sat` + +Descarga de facturas del SAT +============================ El proceso de descarga consiste en estos pasos: From a9651ded67eff0cca469c2e5fdb37c2a4da51485 Mon Sep 17 00:00:00 2001 From: Patricio Paez Date: Wed, 13 May 2015 18:21:21 -0500 Subject: [PATCH 128/167] =?UTF-8?q?Mejorar=20redacci=C3=B3n?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/devel.rst | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/docs/devel.rst b/docs/devel.rst index 0f3d935..48e40bc 100644 --- a/docs/devel.rst +++ b/docs/devel.rst @@ -48,9 +48,10 @@ estos dos métodos: Descarga de facturas del SAT ============================ -El proceso de descarga consiste en estos pasos: +El proceso de descarga mediante la aplicación de CFDIs +del SAT consiste en estos pasos: -#. Conectar a la aplicación de CFDIs del SAT +#. Conectar - Lanzar el navegador - Navegar a la página de login de CFDIs @@ -72,9 +73,10 @@ El proceso de descarga consiste en estos pasos: - Regresar una lista de los valores -#. Descargar cada renglón de los resultados +#. Descargar CFDIs - - Iterar en cada elemento de esta lista: + - Iterar en cada elemento de la lista + de resultados: - Concatenar la URL base de CFDIs con el valor del atributo ``onclick`` From e5ca42bda71b0486093c71a30ab56da94f20ce42 Mon Sep 17 00:00:00 2001 From: Patricio Paez Date: Wed, 13 May 2015 18:25:43 -0500 Subject: [PATCH 129/167] Separar descarga-cfdi --- docs/devel.rst | 48 ++++++++++++++++++++++++++---------------------- 1 file changed, 26 insertions(+), 22 deletions(-) diff --git a/docs/devel.rst b/docs/devel.rst index 48e40bc..9720afb 100644 --- a/docs/devel.rst +++ b/docs/devel.rst @@ -45,6 +45,32 @@ estos dos métodos: - :func:`admin-cfdi.Application._download_sat` +descarga-cfdi +============= + +El avance del proceso se indica al usuario mediante +textos cortos que se muestran en una línea de estado +de la interfase gráfica, en esta secuencia:: + + Abriendo Firefox... + Conectando... + Conectado... + Buscando... + Factura 1 de 12 + Factura 2 de 12 + Factura 3 de 12 + Factura 4 de 12 + Factura 5 de 12 + Factura 6 de 12 + Factura 7 de 12 + Factura 8 de 12 + Factura 9 de 12 + Factura 10 de 12 + Factura 11 de 12 + Factura 12 de 12 + Desconectando... + Desconectado... + Descarga de facturas del SAT ============================ @@ -92,28 +118,6 @@ del SAT consiste en estos pasos: En caso de alguna falla en los primeros tres pasos, la aplicación debe realizar el paso 4. -El avance del proceso se indica al usuario mediante -textos cortos que se muestran en una línea de estado -de la interfase gráfica, en esta secuencia:: - - Abriendo Firefox... - Conectando... - Conectado... - Buscando... - Factura 1 de 12 - Factura 2 de 12 - Factura 3 de 12 - Factura 4 de 12 - Factura 5 de 12 - Factura 6 de 12 - Factura 7 de 12 - Factura 8 de 12 - Factura 9 de 12 - Factura 10 de 12 - Factura 11 de 12 - Factura 12 de 12 - Desconectando... - Desconectado... API === From 89067cc0c726e42f5bc2e4e480310b5f5b2e7571 Mon Sep 17 00:00:00 2001 From: Patricio Paez Date: Wed, 13 May 2015 20:56:23 -0500 Subject: [PATCH 130/167] Accesar los selects directamente - Hora, minuto y segundo para facturas emitidas - Se usan las constantes ya existentes en value.Global.SAT --- admincfdi/pyutil.py | 27 ++++++++++++--------------- 1 file changed, 12 insertions(+), 15 deletions(-) diff --git a/admincfdi/pyutil.py b/admincfdi/pyutil.py index 11179f1..5a9ab4a 100644 --- a/admincfdi/pyutil.py +++ b/admincfdi/pyutil.py @@ -1855,21 +1855,18 @@ def search(self, txt.send_keys(dates[1]) # Hay que seleccionar también la hora, minuto y segundo - self.util.sleep(3) - combos = ( - (self.g.SAT['second'], '59'), - (self.g.SAT['minute'], '59'), - (self.g.SAT['hour'], '23'), - ) - for control, value in combos: - combo = browser.find_element_by_id(control) - combo = browser.find_element_by_id( - self.g.SAT['combos'].format(combo.get_attribute('sb'))) - combo.click() - self.util.sleep(2) - link = browser.find_element_by_link_text(value) - link.click() - self.util.sleep(2) + arg = "document.getElementById('{}')." \ + "value={};".format( + self.g.SAT['hour'], '23') + browser.execute_script(arg) + arg = "document.getElementById('{}')." \ + "value={};".format( + self.g.SAT['minute'], '59') + browser.execute_script(arg) + arg = "document.getElementById('{}')." \ + "value={};".format( + self.g.SAT['second'], '59') + browser.execute_script(arg) if mes_completo_por_día: return self._download_sat_month_emitidas(dates[1], day) # Recibidas From 57e9e752751f42cdc892b75110e14e3ded13d4cb Mon Sep 17 00:00:00 2001 From: Patricio Paez Date: Thu, 14 May 2015 07:08:25 -0500 Subject: [PATCH 131/167] =?UTF-8?q?Prevenir=20error=20espor=C3=A1dico=20de?= =?UTF-8?q?=20elemento=20inexistente?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- admincfdi/pyutil.py | 1 + 1 file changed, 1 insertion(+) diff --git a/admincfdi/pyutil.py b/admincfdi/pyutil.py index 5a9ab4a..f0228d5 100644 --- a/admincfdi/pyutil.py +++ b/admincfdi/pyutil.py @@ -1824,6 +1824,7 @@ def search(self, # Descargar por fecha opt = browser.find_element_by_id(self.g.SAT['date']) opt.click() + wait.until(EC.staleness_of(opt)) if facturas_emitidas: txt = wait.until(EC.element_to_be_clickable( (By.ID, self.g.SAT['receptor']))) From e317e8a127c27f467783f42bb43a71d65533fe10 Mon Sep 17 00:00:00 2001 From: Patricio Paez Date: Sat, 16 May 2015 19:41:04 -0500 Subject: [PATCH 132/167] =?UTF-8?q?Prevenir=20timeout=20en=20b=C3=BAsqueda?= =?UTF-8?q?=20de=20facturas=20emitidas?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - La respuesta del servidor puede tardar más en la búsqueda que con facturas recibidas, se aumenta el valor global del timeout --- admincfdi/pyutil.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/admincfdi/pyutil.py b/admincfdi/pyutil.py index f0228d5..a64fee1 100644 --- a/admincfdi/pyutil.py +++ b/admincfdi/pyutil.py @@ -1813,7 +1813,7 @@ def search(self, page_query = self.g.SAT['page_emisor'] browser.get(page_query) - wait = WebDriverWait(browser, 10) + wait = WebDriverWait(browser, 15) wait.until(EC.title_contains('Buscar CFDI')) self.status('Buscando...') if uuid: From 9c0d2e1899c679d24c7afae0cd5df8b3c6f4aee3 Mon Sep 17 00:00:00 2001 From: Patricio Paez Date: Sun, 17 May 2015 19:11:09 -0500 Subject: [PATCH 133/167] =?UTF-8?q?Mover=20al=20cap=C3=ADtulo=20de=20uso?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/devel.rst | 39 +-------------------------------------- docs/uso.rst | 42 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 38 deletions(-) diff --git a/docs/devel.rst b/docs/devel.rst index 9720afb..2d74b51 100644 --- a/docs/devel.rst +++ b/docs/devel.rst @@ -25,18 +25,7 @@ El paquete `admincfdi` incluye los siguientes módulos: admin-cfdi ========== -La descarga de los archivos XML del sitio web del SAT se -maneja en la primera pestaña de la interfase gráfica. - -Primeramente el usuario debe llenar -datos y/o seleccionar opciones en estos tres apartados: - -- Datos de acceso -- Tipo de consulta -- Opciones de búsqueda - -El proceso de la descarga se inicia mediante el botón -``Descargar``, el cual está ligado al método +El botón ``Descargar`` está ligado al método :func:`admin-cfdi.Application.button_download_sat_click` de la aplicación, que ejecuta estos dos métodos: @@ -45,32 +34,6 @@ estos dos métodos: - :func:`admin-cfdi.Application._download_sat` -descarga-cfdi -============= - -El avance del proceso se indica al usuario mediante -textos cortos que se muestran en una línea de estado -de la interfase gráfica, en esta secuencia:: - - Abriendo Firefox... - Conectando... - Conectado... - Buscando... - Factura 1 de 12 - Factura 2 de 12 - Factura 3 de 12 - Factura 4 de 12 - Factura 5 de 12 - Factura 6 de 12 - Factura 7 de 12 - Factura 8 de 12 - Factura 9 de 12 - Factura 10 de 12 - Factura 11 de 12 - Factura 12 de 12 - Desconectando... - Desconectado... - Descarga de facturas del SAT ============================ diff --git a/docs/uso.rst b/docs/uso.rst index d0fa0dd..b488eeb 100644 --- a/docs/uso.rst +++ b/docs/uso.rst @@ -13,6 +13,48 @@ Admincfdi incluye las siguientes aplicaciones: - `cfdi2pdf` +admin-cfdi +========== + +La descarga de los archivos XML del sitio web del SAT se +maneja en la primera pestaña de la interfase gráfica. + +Primeramente el usuario debe llenar +datos y/o seleccionar opciones en estos tres apartados: + +- Datos de acceso +- Tipo de consulta +- Opciones de búsqueda + +El proceso de la descarga se inicia mediante el botón +``Descargar``. + +descarga-cfdi +============= + +El avance del proceso se indica al usuario mediante +textos cortos que se muestran en una línea de estado +de la interfase gráfica, en esta secuencia:: + + Abriendo Firefox... + Conectando... + Conectado... + Buscando... + Factura 1 de 12 + Factura 2 de 12 + Factura 3 de 12 + Factura 4 de 12 + Factura 5 de 12 + Factura 6 de 12 + Factura 7 de 12 + Factura 8 de 12 + Factura 9 de 12 + Factura 10 de 12 + Factura 11 de 12 + Factura 12 de 12 + Desconectando... + Desconectado... + Pruebas funcionales de descarga del SAT --------------------------------------- From d5c10b9fd546617c5b1a2116234f7f503e08d265 Mon Sep 17 00:00:00 2001 From: Patricio Paez Date: Sun, 17 May 2015 19:17:11 -0500 Subject: [PATCH 134/167] =?UTF-8?q?Agregar=20s=C3=ADntesis=20de=20los=20pa?= =?UTF-8?q?sos?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/devel.rst | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/docs/devel.rst b/docs/devel.rst index 2d74b51..d6b4b3c 100644 --- a/docs/devel.rst +++ b/docs/devel.rst @@ -40,6 +40,13 @@ Descarga de facturas del SAT El proceso de descarga mediante la aplicación de CFDIs del SAT consiste en estos pasos: +#. Conectar +#. Buscar +#. Descargar +#. Desconectar + +Los detalles de cada paso: + #. Conectar - Lanzar el navegador @@ -48,7 +55,7 @@ del SAT consiste en estos pasos: - Enviar los datos al servidor - Esperar la respuesta -#. Realizar una búsqueda +#. Buscar - Navegar a la página de búsqueda de facturas emitidas, o a la de facturas recibidas @@ -62,7 +69,7 @@ del SAT consiste en estos pasos: - Regresar una lista de los valores -#. Descargar CFDIs +#. Descargar - Iterar en cada elemento de la lista de resultados: From 28f342dd22112fba368b9965b384d79e85560439 Mon Sep 17 00:00:00 2001 From: Patricio Paez Date: Sun, 17 May 2015 20:45:54 -0500 Subject: [PATCH 135/167] Agregar detalles de los pasos --- docs/devel.rst | 59 +++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 53 insertions(+), 6 deletions(-) diff --git a/docs/devel.rst b/docs/devel.rst index d6b4b3c..3fdd159 100644 --- a/docs/devel.rst +++ b/docs/devel.rst @@ -54,19 +54,66 @@ Los detalles de cada paso: - Llenar el usuario y la contraseña (RFC y CIEC) - Enviar los datos al servidor - Esperar la respuesta + - El título de la página cambia a ``NetIQ Access Manager`` + - Hay un elemento iframe con id 'content', el cual contiene: + - En caso de éxito, el elemento con clase 'messagetext' + con el texto 'session has been authenticated' . + - En caso de falla, un pop up con el elemento con id 'xacerror' + que contiene el texto ``Login failed`` #. Buscar - Navegar a la página de búsqueda de facturas emitidas, o a la de facturas recibidas - - Seleccionar el tipo de búsqueda + - Esperar a que el título cambie a 'Buscar CFDI' - Llenar los datos de la búsqueda + - Si la búsqueda es por UUID, llenar el UUID en + el input con id ``ctl00_MainContent_TxtUUID``. + - Si la búsqueda es por fecha: + - Hacer clic en el botón de radio a la izquierda + de Fecha de Emisión con id + ``ctl00_MainContent_RdoFechas``. + - Esperar a que el input a la derecha de RFC Emisor + con id ``ctl00_MainContent_TxtRfcReceptor`` + esté habilitado y se pueda hacer clic en él. + - Si se buscan facturas emitidas: + - Habilitar los dos inputs con name + ``ctl00$MainContent$CldFechaInicial2$Calendario_text`` y + ``ctl00$MainContent$CldFechaFinal2$Calendario_text`` + para fecha inicial y + fecha final de emisión + - Llenar esos dos inputs con id + ``ctl00_MainContent_CldFechaInicial2_Calendario_text`` y + ``ctl00_MainContent_CldFechaFinal2_Calendario_text`` + con formato ``dd/mm/aaaa`` + - Asignar a los selects no visibles de hora, minuto y + segundo con ids + ``ctl00_MainContent_CldFechaFinal2_DdlHora`` + ``ctl00_MainContent_CldFechaFinal2_DdlMinuto`` + ``ctl00_MainContent_CldFechaFinal2_DdlSegundo`` + final las cadenas 23, 59 y 59 + - Se se buscan facturas recibidas: + - Asignar a los selects no visibles de año, mes y + día con ids + ``DdlAnio`` + ``ctl00_MainContent_CldFecha_DdlMes`` + ``ctl00_MainContent_CldFecha_DdlDia`` + los valores de los parámetros - Enviar los datos al servidor - - Esperar los resultados - - Encontrar los elementos con atributo ``name`` - igual a *download*, corresponden al ícono - de descarga a la izquierda en cada renglón. - - Regresar una lista de los valores + - Esperar a que no sea visible el elemento div de los + resultados, o el botón mismo de enviar + - Esperar a que uno de los dos div con id + ``ctl00_MainContent_PnlResultados`` o id + ``ctl00_MainContent_PnlNoResultados`` esté + visible. + - Si el div con id ``ctl00_MainContent_PnlResultados`` + es visible: + + - Esperar que un elemento con name ``BtnDescarga`` + se le pueda hacer clic + - Encontrar la lista todos los elementos con name + ``BtnDescarga``. Son los íconos + de descarga a la izquierda en cada renglón. #. Descargar From ed9f237d25a918b7310fb004d7806be28b93ed63 Mon Sep 17 00:00:00 2001 From: Patricio Paez Date: Mon, 18 May 2015 06:31:26 -0500 Subject: [PATCH 136/167] =?UTF-8?q?Explicar=20paginaci=C3=B3n?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/devel.rst | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/docs/devel.rst b/docs/devel.rst index 3fdd159..cb27d7a 100644 --- a/docs/devel.rst +++ b/docs/devel.rst @@ -115,6 +115,16 @@ Los detalles de cada paso: ``BtnDescarga``. Son los íconos de descarga a la izquierda en cada renglón. + - La lista de resultados está paginada en 500 elementos. + Si los + resultados son más de 500, una opción es dividir + la búsqueda en dos o más búsquedas + en las que se agregan criterios: La búsqueda de un + mes se puede dividir en búsquedas por día; la + búsqueda de un día puede dividirse en búsquedas en + un rango de horas en ese día. + + #. Descargar From 7197da2d322d0d9bb5eac2ab890bc756d6ea409b Mon Sep 17 00:00:00 2001 From: Patricio Paez Date: Tue, 19 May 2015 07:03:43 -0500 Subject: [PATCH 137/167] Estandarizar --- docs/devel.rst | 56 +++++++++++++++++++++++++------------------------- 1 file changed, 28 insertions(+), 28 deletions(-) diff --git a/docs/devel.rst b/docs/devel.rst index cb27d7a..d8f978f 100644 --- a/docs/devel.rst +++ b/docs/devel.rst @@ -54,18 +54,18 @@ Los detalles de cada paso: - Llenar el usuario y la contraseña (RFC y CIEC) - Enviar los datos al servidor - Esperar la respuesta - - El título de la página cambia a ``NetIQ Access Manager`` - - Hay un elemento iframe con id 'content', el cual contiene: - - En caso de éxito, el elemento con clase 'messagetext' - con el texto 'session has been authenticated' . - - En caso de falla, un pop up con el elemento con id 'xacerror' - que contiene el texto ``Login failed`` + - El título de la página cambia a *NetIQ Access Manager* + - Hay un elemento iframe con id ``content``, el cual contiene: + - En caso de éxito, el elemento con clase ``messagetext`` + con el texto *session has been authenticated*. + - En caso de falla, un pop up con el elemento con id ``xacerror`` + que contiene el texto *Login failed* #. Buscar - Navegar a la página de búsqueda de facturas emitidas, o a la de facturas recibidas - - Esperar a que el título cambie a 'Buscar CFDI' + - Esperar a que el título cambie a *Buscar CFDI* - Llenar los datos de la búsqueda - Si la búsqueda es por UUID, llenar el UUID en el input con id ``ctl00_MainContent_TxtUUID``. @@ -77,28 +77,28 @@ Los detalles de cada paso: con id ``ctl00_MainContent_TxtRfcReceptor`` esté habilitado y se pueda hacer clic en él. - Si se buscan facturas emitidas: - - Habilitar los dos inputs con name - ``ctl00$MainContent$CldFechaInicial2$Calendario_text`` y - ``ctl00$MainContent$CldFechaFinal2$Calendario_text`` - para fecha inicial y - fecha final de emisión - - Llenar esos dos inputs con id - ``ctl00_MainContent_CldFechaInicial2_Calendario_text`` y - ``ctl00_MainContent_CldFechaFinal2_Calendario_text`` - con formato ``dd/mm/aaaa`` - - Asignar a los selects no visibles de hora, minuto y - segundo con ids - ``ctl00_MainContent_CldFechaFinal2_DdlHora`` - ``ctl00_MainContent_CldFechaFinal2_DdlMinuto`` - ``ctl00_MainContent_CldFechaFinal2_DdlSegundo`` - final las cadenas 23, 59 y 59 + - Habilitar los inputs con id + + - ``ctl00_MainContent_CldFechaInicial2_Calendario_text`` + - ``ctl00_MainContent_CldFechaFinal2_Calendario_text`` + + y asignar valor de fecha inicial y fecha final de emisión + usando formato ``dd/mm/aaaa`` + - Asignar a los selects no visibles de tiempo final con ids + + - ``ctl00_MainContent_CldFechaFinal2_DdlHora`` + - ``ctl00_MainContent_CldFechaFinal2_DdlMinuto`` + - ``ctl00_MainContent_CldFechaFinal2_DdlSegundo`` + + las cadenas 23, 59 y 59 - Se se buscan facturas recibidas: - - Asignar a los selects no visibles de año, mes y - día con ids - ``DdlAnio`` - ``ctl00_MainContent_CldFecha_DdlMes`` - ``ctl00_MainContent_CldFecha_DdlDia`` - los valores de los parámetros + - Asignar a los selects no visibles con ids + + - ``DdlAnio`` + - ``ctl00_MainContent_CldFecha_DdlMes`` + - ``ctl00_MainContent_CldFecha_DdlDia`` + + los valores de los parámetros año, mes y día - Enviar los datos al servidor - Esperar a que no sea visible el elemento div de los resultados, o el botón mismo de enviar From 8267635aa3702ae7b117a7d6530ab294f5c839b4 Mon Sep 17 00:00:00 2001 From: Patricio Paez Date: Sun, 31 May 2015 08:32:43 -0500 Subject: [PATCH 138/167] =?UTF-8?q?Valores=20enviados=20con=20las=20formas?= =?UTF-8?q?=20de=20b=C3=BAsqueda?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/devel.rst | 65 +++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 64 insertions(+), 1 deletion(-) diff --git a/docs/devel.rst b/docs/devel.rst index d8f978f..6b0bd45 100644 --- a/docs/devel.rst +++ b/docs/devel.rst @@ -99,7 +99,9 @@ Los detalles de cada paso: - ``ctl00_MainContent_CldFecha_DdlDia`` los valores de los parámetros año, mes y día - - Enviar los datos al servidor + - Enviar la forma de búsqueda al servidor mediante método POST, los + datos que se envían se muestran más bajo. + - Esperar a que no sea visible el elemento div de los resultados, o el botón mismo de enviar - Esperar a que uno de los dos div con id @@ -145,6 +147,67 @@ Los detalles de cada paso: En caso de alguna falla en los primeros tres pasos, la aplicación debe realizar el paso 4. +Los datos que se envían por la forma de búsqueda de facturas recibidas: + + - ctl00$ScriptManager1=ctl00$MainContent$UpnlBusqueda|ctl00$MainContent$BtnBusqueda + - __CSRFTOKEN=%2FwEFJGNjZmIzNzZmLTE4OWUtNDQwNS1iNmZiLWU2NWE4MDQ0Y2EwZA%3D%3D + - ctl00$MainContent$TxtUUID= + - ctl00$MainContent$FiltroCentral=RdoFechas + - ctl00$MainContent$CldFecha$DdlAnio=2014 + - ctl00$MainContent$CldFecha$DdlMes=1 + - ctl00$MainContent$CldFecha$DdlDia=0 + - ctl00$MainContent$CldFecha$DdlHora=0 + - ctl00$MainContent$CldFecha$DdlMinuto=0 + - ctl00$MainContent$CldFecha$DdlSegundo=0 + - ctl00$MainContent$CldFecha$DdlHoraFin=23 + - ctl00$MainContent$CldFecha$DdlMinutoFin=59 + - ctl00$MainContent$CldFecha$DdlSegundoFin=59 + - ctl00$MainContent$TxtRfcReceptor= + - ctl00$MainContent$DdlEstadoComprobante=-1 + - ctl00$MainContent$hfInicialBool=false + - ctl00$MainContent$ddlComplementos=-1 + - __EVENTTARGET= + - __EVENTARGUMENT= + - __LASTFOCUS= + - __VIEWSTATE= + - __VIEWSTATEGENERATOR=FE9DB3F4 + - __VIEWSTATEENCRYPTED= + - __ASYNCPOST=true + - ctl00$MainContent$BtnBusqueda=Buscar CFDI + +Los datos que se envían por la forma de búsqueda de facturas emitidas: + + - ctl00$ScriptManager1=ctl00$MainContent$UpnlBusqueda|ctl00$MainContent$BtnBusqueda + - __CSRFTOKEN=%2FwEFJGNjZmIzNzZmLTE4OWUtNDQwNS1iNmZiLWU2NWE4MDQ0Y2EwZA%3D%3D + - ctl00$MainContent$TxtUUID= + - ctl00$MainContent$FiltroCentral=RdoFechas + - ctl00$MainContent$hfInicial=2015 + - ctl00$MainContent$hfInicialBool=false + - ctl00$MainContent$CldFechaInicial2$Calendario_text=18/05/2015 + - ctl00$MainContent$CldFechaInicial2$DdlHora=0 + - ctl00$MainContent$CldFechaInicial2$DdlMinuto=0 + - ctl00$MainContent$CldFechaInicial2$DdlSegundo=0 + - ctl00$MainContent$hfFinal=2015 + - ctl00$MainContent$CldFechaFinal2$Calendario_text=19/05/2015 + - ctl00$MainContent$CldFechaFinal2$DdlHora=23 + - ctl00$MainContent$CldFechaFinal2$DdlMinuto=59 + - ctl00$MainContent$CldFechaFinal2$DdlSegundo=59 + - ctl00$MainContent$TxtRfcReceptor= + - ctl00$MainContent$DdlEstadoComprobante=-1 + - ctl00$MainContent$ddlComplementos=-1 + - ctl00$MainContent$hfDatos= + - ctl00$MainContent$hfFlag= + - ctl00$MainContent$hfAux= + - __EVENTTARGET= + - __EVENTARGUMENT= + - __LASTFOCUS= + - __VIEWSTATE= + - __VIEWSTATEGENERATOR=3D1378D8 + - __VIEWSTATEENCRYPTED= + - __ASYNCPOST=true + - ctl00$MainContent$BtnBusqueda=Buscar CFDI + + API === From 1d1992737006ef494bf931b71993f36a6f17282f Mon Sep 17 00:00:00 2001 From: Patricio Paez Date: Sun, 31 May 2015 09:25:37 -0500 Subject: [PATCH 139/167] Agregar scripts para Windows #67 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - admin-cfdi.pyw sin consola - descarga-cfdi.cmd y cfdi2pdf.cmd en consola - actualizar setup.py para que los instale - actualizar documentación --- admin-cfdi.pyw | 6 ++++++ cfdi2pdf.cmd | 3 +++ descarga-cfdi.cmd | 3 +++ docs/uso.rst | 2 ++ setup.py | 3 ++- 5 files changed, 16 insertions(+), 1 deletion(-) create mode 100755 admin-cfdi.pyw create mode 100644 cfdi2pdf.cmd create mode 100644 descarga-cfdi.cmd diff --git a/admin-cfdi.pyw b/admin-cfdi.pyw new file mode 100755 index 0000000..f7c90b9 --- /dev/null +++ b/admin-cfdi.pyw @@ -0,0 +1,6 @@ +import sys +import os.path + + +ac_path = os.path.join(sys.path[0], "admin-cfdi") +exec(open(ac_path).read()) diff --git a/cfdi2pdf.cmd b/cfdi2pdf.cmd new file mode 100644 index 0000000..515cdff --- /dev/null +++ b/cfdi2pdf.cmd @@ -0,0 +1,3 @@ +@echo off +set c2p_path="%~d0\%~p0cfdi2pdf" +py %c2p_path% %* diff --git a/descarga-cfdi.cmd b/descarga-cfdi.cmd new file mode 100644 index 0000000..de7a34e --- /dev/null +++ b/descarga-cfdi.cmd @@ -0,0 +1,3 @@ +@echo off +set dc_path="%~d0\%~p0descarga-cfdi" +py %dc_path% %* diff --git a/docs/uso.rst b/docs/uso.rst index b488eeb..cb7e177 100644 --- a/docs/uso.rst +++ b/docs/uso.rst @@ -12,6 +12,8 @@ Admincfdi incluye las siguientes aplicaciones: - `cfdi2pdf` +`admin-cfdi` es una aplicación gráfica, `descarga-cfdi` +y `cfdi2pdf` son aplicaciones de línea de comando. admin-cfdi ========== diff --git a/setup.py b/setup.py index c090b07..0ae1fea 100644 --- a/setup.py +++ b/setup.py @@ -14,6 +14,7 @@ packages=find_packages(), install_requires=['pypng', 'fpdf', 'pygubu', 'selenium', 'pyqrcode', 'requests'], package_data = {'': ['img/*.png', 'img/*.gif', 'ui/*', 'template/*']}, - scripts=['admin-cfdi','descarga-cfdi', 'cfdi2pdf']) + scripts=['admin-cfdi','descarga-cfdi', 'cfdi2pdf', + 'admin-cfdi.pyw', 'descarga-cfdi.cmd', 'cfdi2pdf.cmd']) # vim: ts=4 et sw=4 From 874004c48409d550d094a7266255a23ec2f76520 Mon Sep 17 00:00:00 2001 From: Patricio Paez Date: Sun, 31 May 2015 09:49:58 -0500 Subject: [PATCH 140/167] Soportar HOMEPATH en Windows #65 --- descarga-cfdi | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/descarga-cfdi b/descarga-cfdi index c783233..f912d65 100755 --- a/descarga-cfdi +++ b/descarga-cfdi @@ -37,8 +37,11 @@ def process_command_line_arguments(): action='store_const', const=True, help=help, default=False) + home_path = os.environ.get('HOME') + if not home_path: + home_path = os.environ.get('HOMEPATH', '') default_carpeta_destino = os.path.join( - os.environ.get('HOME'), 'cfdi-descarga') + home_path, 'cfdi-descarga') help = 'Carpeta local para guardar los CFDIs descargados ' \ 'El predeterminado es %(default)s' parser.add_argument('--carpeta-destino', From 4b17062fa695c4933f9fcbc417c38b423a2eef92 Mon Sep 17 00:00:00 2001 From: Patricio Paez Date: Sun, 31 May 2015 10:57:37 -0500 Subject: [PATCH 141/167] Corregir error por codepage 437 en consola MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Queda pendiente arreglar acentos y ñ --- cfdi2pdf.cmd | 1 + descarga-cfdi.cmd | 1 + 2 files changed, 2 insertions(+) mode change 100644 => 100755 descarga-cfdi.cmd diff --git a/cfdi2pdf.cmd b/cfdi2pdf.cmd index 515cdff..ce6bcfe 100644 --- a/cfdi2pdf.cmd +++ b/cfdi2pdf.cmd @@ -1,3 +1,4 @@ @echo off +set PYTHONIOENCODING=utf-8 set c2p_path="%~d0\%~p0cfdi2pdf" py %c2p_path% %* diff --git a/descarga-cfdi.cmd b/descarga-cfdi.cmd old mode 100644 new mode 100755 index de7a34e..7cdb918 --- a/descarga-cfdi.cmd +++ b/descarga-cfdi.cmd @@ -1,3 +1,4 @@ @echo off +set PYTHONIOENCODING=utf-8 set dc_path="%~d0\%~p0descarga-cfdi" py %dc_path% %* From 7b4323cd4c40ab72244f5148082a7320dee9add6 Mon Sep 17 00:00:00 2001 From: Mauricio Baeza Date: Sat, 25 Jul 2015 23:47:37 -0500 Subject: [PATCH 142/167] Soporte para generar PDF desde CSV desde GUI --- admin-cfdi | 108 ++++++++++++++--------- admincfdi/pyutil.py | 36 ++++---- admincfdi/template/plantilla_factura.ods | Bin 0 -> 51304 bytes admincfdi/values.py | 6 +- cfdi2pdf | 20 ++++- requirements.txt | 6 ++ setup.py | 21 +++-- 7 files changed, 128 insertions(+), 69 deletions(-) create mode 100644 admincfdi/template/plantilla_factura.ods create mode 100644 requirements.txt diff --git a/admin-cfdi b/admin-cfdi index a720e22..f62d26f 100644 --- a/admin-cfdi +++ b/admin-cfdi @@ -126,6 +126,7 @@ class Application(pygubu.TkApplication): if not LIBO: self._config('radio_ods', {'state': 'disabled'}) + self.pb = self._get_object('progressbar') return def _center_window(self, root): @@ -898,7 +899,7 @@ class Application(pygubu.TkApplication): def button_select_folder_target_pdf_click(self): folder = self.util.get_folder(self.parent) if folder: - self._set('target_pdf', folder) + self._set('pdf_target', folder) return def button_select_template_ods_click(self): @@ -1043,38 +1044,58 @@ class Application(pygubu.TkApplication): if not ok: return - files = data['files'] - del data['files'] - total = len(files) + if data['pdf_user']['template_csv']: + self._csv_to_pdf(data) + return - libo = LibO() - if libo.SM is None: - del libo - msg = 'No fue posible iniciar LibreOffice, asegurate de que ' \ - 'este correctamente instalado e intentalo de nuevo' - self.util.msgbox(msg) - return False, data + #~ libo = LibO() + #~ if libo.SM is None: + #~ del libo + #~ msg = 'No fue posible iniciar LibreOffice, asegurate de que ' \ + #~ 'este correctamente instalado e intentalo de nuevo' + #~ self.util.msgbox(msg) + #~ return False, data - pb = self._get_object('progressbar') - pb['maximum'] = total - pb.start() + files = data['files'] + total = len(files) + #~ self.pb = self._get_object('progressbar') + self.pb['maximum'] = total + self.pb.start() j = 0 for i, f in enumerate(files): - pb['value'] = i + 1 + self.pb['value'] = i + 1 msg = 'Archivo {} de {}'.format(i + 1, total) self._set('msg_user', msg) self.parent.update_idletasks() if self._make_pdf(f, libo, data['pdf_user']): j += 1 del libo - pb['value'] = 0 - pb.stop() + self.pb['value'] = 0 + self.pb.stop() msg = 'Archivos XML encontrados: {}\n' msg += 'Archivos PDF generados: {}' msg = msg.format(total, j) self.util.msgbox(msg) return + def _csv_to_pdf(self, data): + files = data['files'] + user = data['pdf_user'] + total = len(files) + self.pb['maximum'] = total + self.pb.start() + for i, f in enumerate(files): + args = (self.g.SCRIPTS['CFDI2PDF'], '-a', f, '-o', + user['pdf_source'], '-d', user['pdf_target'], '-p', user['template_csv']) + self.util.call(args) + self.pb['value'] = i + 1 + msg = 'Archivo {} de {}'.format(i + 1, total) + self._set('msg_user', msg) + self.parent.update_idletasks() + self.pb['value'] = 0 + self.pb.stop() + return + def _make_pdf(self, path, libo, data): xml = self.util.parse(path) if xml is None: @@ -1102,36 +1123,46 @@ class Application(pygubu.TkApplication): return False def _validate_pdf(self): + data = {} pdf_user = self._get('current_user_pdf') if not pdf_user: msg = 'Selecciona un usuario primero' self.util.msgbox(msg) return False, {} - data = self.users_pdf[pdf_user] - if not self.util.validate_dir(data['pdf_source'], 'r'): + pdf_user = self.users_pdf[pdf_user] + if not self.util.validate_dir(pdf_user['pdf_source'], 'r'): msg = 'No tienes derechos de lectura en el directorio origen' self.util.msgbox(msg) return False, {} - files = self.util.get_files(data['pdf_source']) + files = self.util.get_files(pdf_user['pdf_source']) if not files: msg = 'No se encontraron archivos XML en el directorio origen' self.util.msgbox(msg) return False, {} - if not self.util.validate_dir(data['pdf_target'], 'w'): + if not self.util.validate_dir(pdf_user['pdf_target'], 'w'): msg = 'No tienes derechos de escritura en el directorio destino' self.util.msgbox(msg) return False, {} - template_ods = data['template_ods'] - if data['template_ods']: - if not self.util.exists(data['template_ods']): + data['pdf_user'] = pdf_user + data['files'] = files + data['libo'] = None + if pdf_user['template_csv']: + msg = 'Se van a generar {} PDFs\n\n¿Estas seguro de continuar?' + if not self.util.question(msg.format(len(files))): + return False, {} + return True, data + + #~ template_ods = pdf_user['template_ods'] + if pdf_user['template_ods']: + if not self.util.exists(pdf_user['template_ods']): msg = 'No se encontró una plantilla en la ruta establecida' self.util.msgbox(msg) return False, {} - ext = self.util.get_path_info(data['template_ods'], 3) + ext = self.util.get_path_info(pdf_user['template_ods'], 3) if ext != self.g.EXT_ODS: msg = 'La plantilla no es un archivo ODS de Calc' self.util.msgbox(msg) @@ -1142,25 +1173,20 @@ class Application(pygubu.TkApplication): msg = 'No fue posible iniciar LibreOffice, asegurate de que ' \ 'este correctamente instalado e intentalo de nuevo' self.util.msgbox(msg) - return False, data - doc = libo.doc_open(data['template_ods']) - if doc is None: - del libo - msg = 'No fue posible abrir la plantilla, asegurate de que ' \ - 'la ruta sea correcta' - self.util.msgbox(msg) - return False, data - doc.dispose() - del libo - else: # ToDO CSV - return False, {} - + return False, {} + #~ doc = libo.doc_open(data['template_ods']) + #~ if doc is None: + #~ del libo + #~ msg = 'No fue posible abrir la plantilla, asegurate de que ' \ + #~ 'la ruta sea correcta' + #~ self.util.msgbox(msg) + #~ return False, data + #~ doc.dispose() + #~ del libo msg = 'Se van a generar {} PDFs\n\n¿Estas seguro de continuar?' if not self.util.question(msg.format(len(files))): return False, {} - - data['pdf_user'] = data - data['files'] = files + data['libo'] = libo return True, data def button_select_folder_report_source_click(self): diff --git a/admincfdi/pyutil.py b/admincfdi/pyutil.py index a64fee1..b3a604e 100644 --- a/admincfdi/pyutil.py +++ b/admincfdi/pyutil.py @@ -55,7 +55,10 @@ LIBO = True if sys.platform == WIN: - from win32com.client import Dispatch + try: + from win32com.client import Dispatch + except ImportError: + LIBO = False elif sys.platform == LINUX: try: import uno @@ -338,6 +341,9 @@ class Util(object): def __init__(self): self.OS = sys.platform + def call(self, *arg): + return subprocess.call(*arg) + def get_folder(self, parent): return askdirectory(parent=parent) @@ -1707,6 +1713,7 @@ def __call__(self, driver): def _element_if_visible(element): return element if element.is_displayed() else False + class DescargaSAT(object): def __init__(self, status_callback=print, @@ -2033,7 +2040,7 @@ class CSVPDF(FPDF): LIMIT_MARGIN = 260 DECIMALES = 2 - def __init__(self, path_xml, status_callback=print): + def __init__(self, path_xml, path_template='', status_callback=print): super().__init__(format='Letter') self.status = status_callback try: @@ -2061,7 +2068,7 @@ def __init__(self, path_xml, status_callback=print): if self.xml: self.version = self.xml.attrib['version'] #~ self.cadena = self._get_cadena(path_xml) - self._parse_csv() + self._parse_csv(path_template) decimales = len(self.xml.attrib['total'].split('.')[1]) self.currency = '{0:,.%sf}' % decimales self.monedas = { @@ -2731,25 +2738,19 @@ def _get_timbre(self, pre): def _get_cadena(self): return self.G.CADENA.format(**self.timbre) - #~ from lxml import etree - #~ file_xslt = self.G.PATHS['CADENA'].format(self.version) - #~ styledoc = etree.parse(file_xslt) - #~ transform = etree.XSLT(styledoc) - #~ parser = etree.XMLParser(ns_clean=True, recover=True, encoding='utf-8') - #~ doc = etree.fromstring(xml.encode('utf-8'), parser=parser) - #~ result = str(transform(doc)) - return '' - - def _parse_csv(self, emisor_rfc=''): + def _parse_csv(self, template='', emisor_rfc=''): "Parse template format csv file and create elements dict" keys = ('x', 'y', 'w', 'h', 'font', 'size', 'style', 'foreground', 'background', 'align', 'priority', 'multiline', 'border', 'text') self.elements = {} - path_template = '{}/{}.csv'.format( - self.G.PATHS['TEMPLATE'], - emisor_rfc.lower() - ) + if template: + path_template = template + else: + path_template = '{}/{}.csv'.format( + self.G.PATHS['TEMPLATE'], + emisor_rfc.lower() + ) if not os.path.exists(path_template): path_template = '{}/default.csv'.format(self.G.PATHS['TEMPLATE']) reader = csv.reader(open(path_template, 'r'), delimiter=',') @@ -2763,4 +2764,5 @@ def _parse_csv(self, emisor_rfc=''): else: new_list.append(eval(v.strip())) self.elements[row[0][1:-1]] = dict(zip(keys, new_list)) + return diff --git a/admincfdi/template/plantilla_factura.ods b/admincfdi/template/plantilla_factura.ods new file mode 100644 index 0000000000000000000000000000000000000000..6f42094b2385415940d9f2229cef914f4f627ead GIT binary patch literal 51304 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 Date: Sun, 26 Jul 2015 11:10:50 -0500 Subject: [PATCH 143/167] Corregida una asignacion de variable al generar PDF desde ODS --- admin-cfdi | 25 ++++++------------------- 1 file changed, 6 insertions(+), 19 deletions(-) diff --git a/admin-cfdi b/admin-cfdi index f62d26f..415d246 100644 --- a/admin-cfdi +++ b/admin-cfdi @@ -1048,17 +1048,12 @@ class Application(pygubu.TkApplication): self._csv_to_pdf(data) return - #~ libo = LibO() - #~ if libo.SM is None: - #~ del libo - #~ msg = 'No fue posible iniciar LibreOffice, asegurate de que ' \ - #~ 'este correctamente instalado e intentalo de nuevo' - #~ self.util.msgbox(msg) - #~ return False, data + self._ods_to_pdf(data) + return + def _ods_to_pdf(self, data): files = data['files'] total = len(files) - #~ self.pb = self._get_object('progressbar') self.pb['maximum'] = total self.pb.start() j = 0 @@ -1067,9 +1062,9 @@ class Application(pygubu.TkApplication): msg = 'Archivo {} de {}'.format(i + 1, total) self._set('msg_user', msg) self.parent.update_idletasks() - if self._make_pdf(f, libo, data['pdf_user']): + if self._make_pdf(f, data['libo'], data['pdf_user']): j += 1 - del libo + del data['libo'] self.pb['value'] = 0 self.pb.stop() msg = 'Archivos XML encontrados: {}\n' @@ -1174,15 +1169,7 @@ class Application(pygubu.TkApplication): 'este correctamente instalado e intentalo de nuevo' self.util.msgbox(msg) return False, {} - #~ doc = libo.doc_open(data['template_ods']) - #~ if doc is None: - #~ del libo - #~ msg = 'No fue posible abrir la plantilla, asegurate de que ' \ - #~ 'la ruta sea correcta' - #~ self.util.msgbox(msg) - #~ return False, data - #~ doc.dispose() - #~ del libo + msg = 'Se van a generar {} PDFs\n\n¿Estas seguro de continuar?' if not self.util.question(msg.format(len(files))): return False, {} From a48b73b746856a584da6fb05737e20328eae940e Mon Sep 17 00:00:00 2001 From: Mauricio Baeza Date: Wed, 12 Aug 2015 13:12:46 -0500 Subject: [PATCH 144/167] =?UTF-8?q?Se=20agrega=20permiso=20de=20ejecuci?= =?UTF-8?q?=C3=B3n=20al=20archivo=20admin-cfdi?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- admin-cfdi | 0 1 file changed, 0 insertions(+), 0 deletions(-) mode change 100644 => 100755 admin-cfdi diff --git a/admin-cfdi b/admin-cfdi old mode 100644 new mode 100755 From ac469d9fa3ab7134a83072fd7ef2a74a8b702027 Mon Sep 17 00:00:00 2001 From: Mauricio Baeza Date: Wed, 12 Aug 2015 13:20:14 -0500 Subject: [PATCH 145/167] Actualizado gitignore.\nCorregido un error en setup --- .gitignore | 3 +++ README.rst | 6 +++--- setup.py | 2 +- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/.gitignore b/.gitignore index 244740d..d3853ed 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,6 @@ *.swp ======= docs/_build +build +dist +Admin_CFDI.egg-info diff --git a/README.rst b/README.rst index 3a71331..08538dc 100644 --- a/README.rst +++ b/README.rst @@ -8,7 +8,7 @@ admin-cfdi 01/04/2015 :Ultima Versión: - 0.2.7 + 0.3.0 Descripción @@ -44,8 +44,8 @@ GNU & LInux :: - sudo pip install selenium pygubu - python admincfdi.py + sudo install setup.py + ./admin-cfdi ArchLinux _________ diff --git a/setup.py b/setup.py index 28b3f1b..aa302b8 100644 --- a/setup.py +++ b/setup.py @@ -6,7 +6,7 @@ from pip.req import parse_requirements -install_reqs = parse_requirements('requirements.txt') +install_reqs = parse_requirements('requirements.txt', session=False) reqs = [str(ir.req) for ir in install_reqs] setup(name='Admin-CFDI', From 31b8ff45bb1caf414d7e628b96c07562bf7cff9a Mon Sep 17 00:00:00 2001 From: Mauricio Baeza Date: Wed, 12 Aug 2015 13:22:18 -0500 Subject: [PATCH 146/167] Actualizado README --- README.rst | 19 +++++-------------- 1 file changed, 5 insertions(+), 14 deletions(-) diff --git a/README.rst b/README.rst index 08538dc..b3d7544 100644 --- a/README.rst +++ b/README.rst @@ -44,27 +44,18 @@ GNU & LInux :: - sudo install setup.py - ./admin-cfdi + sudo python setup.py install + admin-cfdi -ArchLinux -_________ - - -:: - - sudo pip install selenium pygubu - python admincfdi.py Linux Mint __________ - :: sudo apt-get install python3-pip python3-tk - sudo pip3 install selenium pygubu - python3 admincfdi.py + sudo python setup.py install + admin-cfdi Windows @@ -74,7 +65,7 @@ Si usas Windows, asegúrate de abrir el script con el ejecutable pythonw.exe loc :: - pip install selenium pygubu + python setup.py install Ligas From 7b1fc97f6134b38c5615de2af5e34b9a8f564097 Mon Sep 17 00:00:00 2001 From: Mauricio Baeza Date: Mon, 23 Nov 2015 15:41:40 -0600 Subject: [PATCH 147/167] FIX - Variable COLOR_RED en pyutil --- admincfdi/pyutil.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/admincfdi/pyutil.py b/admincfdi/pyutil.py index b3a604e..45e8e1d 100644 --- a/admincfdi/pyutil.py +++ b/admincfdi/pyutil.py @@ -2065,11 +2065,14 @@ def __init__(self, path_xml, path_template='', status_callback=print): self.data = {} self.elements = {} decimales = self.DECIMALES + self.COLOR_RED = self._color(255, 0, 0) if self.xml: self.version = self.xml.attrib['version'] #~ self.cadena = self._get_cadena(path_xml) self._parse_csv(path_template) decimales = len(self.xml.attrib['total'].split('.')[1]) + if decimales == 1: + decimales = self.DECIMALES self.currency = '{0:,.%sf}' % decimales self.monedas = { 'peso': ('peso', '$', 'm.n.'), From 6419e83a2cf3e4311fc1b32751889ff30494be81 Mon Sep 17 00:00:00 2001 From: Patricio Paez Date: Fri, 29 Jan 2016 18:30:55 -0600 Subject: [PATCH 148/167] Crear lee_credenciales() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Se establece una convención en status para simplificar el manejo de errores en las aplicaciones: 'Ok' cuando hay éxito, un mensaje de error en otros casos. - Se definen dos casos de error que serían los más frecuentes y las pruebas unitarias los cubren. - Si más adelante se implementa la encriptación del archivo de credenciales, se puede agregar a este nivel sin modificar las aplicaciones. --- admincfdi/pyutil.py | 16 ++++++++++++++ admincfdi/tests/test_pyutil.py | 39 ++++++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+) diff --git a/admincfdi/pyutil.py b/admincfdi/pyutil.py index 45e8e1d..fca9c77 100644 --- a/admincfdi/pyutil.py +++ b/admincfdi/pyutil.py @@ -437,6 +437,22 @@ def sleep(self, sec=1): time.sleep(sec) return + def lee_credenciales(self, ruta_archivo): + '''Lee un renglón de ruta_archivo, regresa status, rfc, pwd + + status es 'Ok', o un mensaje de error''' + + try: + f = open(ruta_archivo) + except FileNotFoundError: + return 'Archivo no encontrado: ' + ruta_archivo, '', '' + row = f.readline().strip() + fields = row.split() + if not len(fields) == 2: + return 'No contiene dos campos: ' + ruta_archivo, '', '' + rfc, pwd = fields + return 'Ok', rfc, pwd + def load_config(self, path): data = {} try: diff --git a/admincfdi/tests/test_pyutil.py b/admincfdi/tests/test_pyutil.py index 9f6f29a..47a919d 100644 --- a/admincfdi/tests/test_pyutil.py +++ b/admincfdi/tests/test_pyutil.py @@ -1,6 +1,45 @@ import unittest +class LeeCredenciales(unittest.TestCase): + + def setUp(self): + from unittest.mock import Mock + from admincfdi import pyutil + + self.onefile = Mock() + self.onefile.readline.return_value = 'rfc pwd' + pyutil.open = Mock(return_value=self.onefile) + + def tearDown(self): + from admincfdi import pyutil + + del pyutil.open + + def test_file_not_found(self): + from admincfdi import pyutil + + pyutil.open.side_effect = FileNotFoundError + util = pyutil.Util() + status, rfc, pwd = util.lee_credenciales('ruta') + self.assertEqual('Archivo no encontrado: ruta', status) + + def test_not_two_fields(self): + from admincfdi import pyutil + + self.onefile.readline.return_value = '' + util = pyutil.Util() + status, rfc, pwd = util.lee_credenciales('ruta') + self.assertEqual('No contiene dos campos: ruta', status) + + def test_success(self): + from admincfdi import pyutil + + util = pyutil.Util() + status, rfc, pwd = util.lee_credenciales('ruta') + self.assertEqual('Ok', status) + + class DescargaSAT(unittest.TestCase): def setUp(self): From f24367760c07d8c73ee817bf4d75c6725d09f3ed Mon Sep 17 00:00:00 2001 From: Patricio Paez Date: Fri, 29 Jan 2016 19:02:27 -0600 Subject: [PATCH 149/167] Usar lee_credenciales() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - descarga-cfdi ahora informa al usuario sobre dos posibles fallas al leer las credenciales y termina con código de salida 1. - las pruebas funcionales verifican que se cargaron las credenciales antes de cada prueba, en caso de un error éste se muestra claramente. --- descarga-cfdi | 8 +++++++- functional_DescargaSAT.py | 5 ++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/descarga-cfdi b/descarga-cfdi index f912d65..483e514 100755 --- a/descarga-cfdi +++ b/descarga-cfdi @@ -17,8 +17,10 @@ import time import datetime import os import getpass +import sys from admincfdi.pyutil import DescargaSAT +from admincfdi.pyutil import Util def process_command_line_arguments(): @@ -90,7 +92,11 @@ def main(): rfc = input('RFC: ') pwd = getpass.getpass('CIEC: ') else: - rfc, pwd = open(args.archivo_de_credenciales).readline()[:-1].split() + util = Util() + status, rfc, pwd = util.lee_credenciales(args.archivo_de_credenciales) + if status != 'Ok': + print(status) + sys.exit(1) descarga = DescargaSAT() profile = descarga.get_firefox_profile(args.carpeta_destino) try: diff --git a/functional_DescargaSAT.py b/functional_DescargaSAT.py index e97e9c5..5cce63c 100755 --- a/functional_DescargaSAT.py +++ b/functional_DescargaSAT.py @@ -5,11 +5,14 @@ class DescargaSAT(unittest.TestCase): def setUp(self): import configparser + from admincfdi.pyutil import Util self.config = configparser.ConfigParser() self.config.read('functional_DescargaSAT.conf' ) - self.rfc, self.ciec = open('credenciales.conf').readline()[:-1].split() + util = Util() + status, self.rfc, self.ciec = util.lee_credenciales('credenciales.conf') + self.assertEqual('Ok', status) def test_connect_disconnect(self): from admincfdi.pyutil import DescargaSAT From d9813b1f781051bf93555be1561315c4b5633500 Mon Sep 17 00:00:00 2001 From: Patricio Paez Date: Sun, 31 Jan 2016 07:31:08 -0600 Subject: [PATCH 150/167] Agregar prueba MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Asegurar que espacios adicionales y tabulador introducidos por el usuario, o fin de línea y retorno introducido por el editor no afectan --- admincfdi/tests/test_pyutil.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/admincfdi/tests/test_pyutil.py b/admincfdi/tests/test_pyutil.py index 47a919d..7c6948d 100644 --- a/admincfdi/tests/test_pyutil.py +++ b/admincfdi/tests/test_pyutil.py @@ -39,6 +39,15 @@ def test_success(self): status, rfc, pwd = util.lee_credenciales('ruta') self.assertEqual('Ok', status) + def test_surplus_whitespace_is_ok(self): + from admincfdi import pyutil + + self.onefile.readline.return_value = ' \t rfc \t pwd \r\n' + util = pyutil.Util() + status, rfc, pwd = util.lee_credenciales('ruta') + self.assertEqual('rfc', rfc) + self.assertEqual('pwd', pwd) + class DescargaSAT(unittest.TestCase): From abb93e046141cf4fd7e116a963ab7ec3a9920f06 Mon Sep 17 00:00:00 2001 From: Mauricio Baeza Date: Mon, 1 Feb 2016 13:28:18 -0600 Subject: [PATCH 151/167] =?UTF-8?q?Fix=20-=20Correcci=C3=B3n=20al=20obtene?= =?UTF-8?q?r=20los=20decimales?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- admincfdi/pyutil.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/admincfdi/pyutil.py b/admincfdi/pyutil.py index fca9c77..aa52c8d 100644 --- a/admincfdi/pyutil.py +++ b/admincfdi/pyutil.py @@ -2086,9 +2086,14 @@ def __init__(self, path_xml, path_template='', status_callback=print): self.version = self.xml.attrib['version'] #~ self.cadena = self._get_cadena(path_xml) self._parse_csv(path_template) - decimales = len(self.xml.attrib['total'].split('.')[1]) - if decimales == 1: + + try: + decimales = len(self.xml.attrib['total'].split('.')[1]) + except IndexError: decimales = self.DECIMALES + if decimales == 1: + decimales = self.DECIMALES + self.currency = '{0:,.%sf}' % decimales self.monedas = { 'peso': ('peso', '$', 'm.n.'), From f3929688de060d3f62de7e2a09f4ab7fdfa8ab2d Mon Sep 17 00:00:00 2001 From: Mauricio Baeza Date: Mon, 1 Feb 2016 13:38:18 -0600 Subject: [PATCH 152/167] =?UTF-8?q?Fix=20-=20Correcci=C3=B3n=20al=20obtene?= =?UTF-8?q?r=20los=20decimales?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- admincfdi/pyutil.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/admincfdi/pyutil.py b/admincfdi/pyutil.py index aa52c8d..cc1eed6 100644 --- a/admincfdi/pyutil.py +++ b/admincfdi/pyutil.py @@ -2091,8 +2091,8 @@ def __init__(self, path_xml, path_template='', status_callback=print): decimales = len(self.xml.attrib['total'].split('.')[1]) except IndexError: decimales = self.DECIMALES - if decimales == 1: - decimales = self.DECIMALES + if decimales == 1: + decimales = self.DECIMALES self.currency = '{0:,.%sf}' % decimales self.monedas = { From 793ba5e92ad8a039b9bf6085dafd577fbc2ff06a Mon Sep 17 00:00:00 2001 From: Patricio Paez Date: Tue, 2 Feb 2016 18:47:22 -0600 Subject: [PATCH 153/167] Fix - admin-cfdi.pyw --- admin-cfdi.pyw | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/admin-cfdi.pyw b/admin-cfdi.pyw index f7c90b9..e948acf 100755 --- a/admin-cfdi.pyw +++ b/admin-cfdi.pyw @@ -3,4 +3,4 @@ import os.path ac_path = os.path.join(sys.path[0], "admin-cfdi") -exec(open(ac_path).read()) +exec(open(ac_path, encoding='utf-8').read()) From 9927f54ae91b88f5ab34f795d206dd801c7b3f5f Mon Sep 17 00:00:00 2001 From: Patricio Paez Date: Sun, 31 Jan 2016 09:31:51 -0600 Subject: [PATCH 154/167] =?UTF-8?q?Mejorar=20la=20introducci=C3=B3n?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Mostrar la ventana principal de admin-cfdi. A 50% de escala pero permite agrandar a 100% haciendo clic. - Mencionar ambas aplicaciones de línea de comando, mostrando un ejemplo de su salida --- docs/img/admin-cfdi-ventana-ppal.png | Bin 0 -> 39433 bytes docs/intro.rst | 67 ++++++++++++++++++++++++++- 2 files changed, 65 insertions(+), 2 deletions(-) create mode 100644 docs/img/admin-cfdi-ventana-ppal.png diff --git a/docs/img/admin-cfdi-ventana-ppal.png b/docs/img/admin-cfdi-ventana-ppal.png new file mode 100644 index 0000000000000000000000000000000000000000..704128b0e2e84c7462881e32f68b0a217e11993b GIT binary patch literal 39433 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 literal 0 HcmV?d00001 diff --git a/docs/intro.rst b/docs/intro.rst index 1b7611a..3290bb7 100644 --- a/docs/intro.rst +++ b/docs/intro.rst @@ -2,9 +2,72 @@ Introducción ============ -:term:`admin-cfdi` es una aplicación desarrollada en `Python`_ para descargar +:term:`admin-cfdi` es una aplicación de escritorio desarrollada en `Python`_ para descargar documentos :term:`CFDI` (facturas electrónicas) directamente del :term:`SAT`, permite también, descargar desde correos electrónicos, validarlos y administrarlos. - .. _Python: http://python.org/ + +.. image:: img/admin-cfdi-ventana-ppal.png + :align: center + :width: 714 + :height: 762 + :scale: 50 % + :alt: Haz clic para agrandar + +Se tienen también dos aplicaciones de línea de comando que permiten +automatizar operaciones mediante un script: + +``descarga-cfdi`` descarga CFDIs que cumplan determinado criterio, por +ejemplo todos los CFDIs recibidos en el mes de enero de 2015:: + + descarga-cfdi --año 2015 --mes 01 + + Abriendo Firefox... + Conectando... + Conectado... + Buscando... + Factura 1 de 13 + Factura 2 de 13 + Factura 3 de 13 + Factura 4 de 13 + Factura 5 de 13 + Factura 6 de 13 + Factura 7 de 13 + Factura 8 de 13 + Factura 9 de 13 + Factura 10 de 13 + Factura 11 de 13 + Factura 12 de 13 + Factura 13 de 13 + Desconectando... + Desconectado.. + +Los CFDIs se guardan por omisión en la carpeta `cfdi-descarga`:: + + 12FB2D4B-CAE0-41CF-B344-13FE5135C773.xml 5A5108B2-2171-49B0-86D4-539DD205786A.xml CB969AF4-0E13-441B-9CC7-0AA11831317F.xml + 1FBFA93D-F171-0B0E-CF71-4216C214E66F.xml 61F50926-7C47-4269-B612-3777881050A4.xml F1ABE4CE-9444-4F77-A3E5-57A6559F6CB3.xml + 2968F314-90D6-4000-BBA5-E17988F2870F.xml 79FE35B0-636E-4163-8BA2-38E053E97E4C.xml FF31423C-E1BC-4A3D-9A7B-472FFE9988F9.xml + 2CF33F44-2E2A-4F4C-904C-6213D3E8F12C.xml + +``cfdi2pdf`` convierte los CFDIs de una carpeta origen a formato PDF:: + + cfdi2pdf -o cfdi-descarga/ -d cfdi-pdf/ + + Generando: ../cfdi-descarga/2CF33F44-2E2A-4F4C-904C-6213D3E8F12C.xml + Generando: ../cfdi-descarga/79FE35B0-636E-4163-8BA2-38E053E97E4C.xml + Generando: ../cfdi-descarga/61F50926-7C47-4269-B612-3777881050A4.xml + Generando: ../cfdi-descarga/1FBFA93D-F171-0B0E-CF71-4216C214E66F.xml + Generando: ../cfdi-descarga/F1ABE4CE-9444-4F77-A3E5-57A6559F6CB3.xml + Generando: ../cfdi-descarga/2968F314-90D6-4000-BBA5-E17988F2870F.xml + Generando: ../cfdi-descarga/FF31423C-E1BC-4A3D-9A7B-472FFE9988F9.xml + Generando: ../cfdi-descarga/CB969AF4-0E13-441B-9CC7-0AA11831317F.xml + Generando: ../cfdi-descarga/5A5108B2-2171-49B0-86D4-539DD205786A.xml + Generando: ../cfdi-descarga/12FB2D4B-CAE0-41CF-B344-13FE5135C773.xml + +Los archivos PDF generados se guardan en la carpeta de destino:: + + 12FB2D4B-CAE0-41CF-B344-13FE5135C773.pdf 5A5108B2-2171-49B0-86D4-539DD205786A.pdf F1ABE4CE-9444-4F77-A3E5-57A6559F6CB3.pdf + 1FBFA93D-F171-0B0E-CF71-4216C214E66F.pdf 61F50926-7C47-4269-B612-3777881050A4.pdf FF31423C-E1BC-4A3D-9A7B-472FFE9988F9.pdf + 2968F314-90D6-4000-BBA5-E17988F2870F.pdf 79FE35B0-636E-4163-8BA2-38E053E97E4C.pdf + 2CF33F44-2E2A-4F4C-904C-6213D3E8F12C.pdf CB969AF4-0E13-441B-9CC7-0AA11831317F.pdf From 4e248f4afb24a5f9a7af9afea42ac38bb6cc6188 Mon Sep 17 00:00:00 2001 From: Patricio Paez Date: Sun, 31 Jan 2016 11:24:20 -0600 Subject: [PATCH 155/167] Agregar ejemplo de PDF y mencionar la licencia --- docs/img/ejemplo-pdf.png | Bin 0 -> 147542 bytes docs/intro.rst | 19 +++++++++++++------ 2 files changed, 13 insertions(+), 6 deletions(-) create mode 100644 docs/img/ejemplo-pdf.png diff --git a/docs/img/ejemplo-pdf.png b/docs/img/ejemplo-pdf.png new file mode 100644 index 0000000000000000000000000000000000000000..52f49daaf1519cd859fb6248dc80076b56256998 GIT binary patch literal 147542 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 V<{cY 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 Date: Tue, 2 Feb 2016 23:15:35 -0600 Subject: [PATCH 156/167] =?UTF-8?q?Quitar=20acento=20en=20html=5Ftitle=20e?= =?UTF-8?q?n=20conf.py=20para=20que=20se=20genere=20correctamente=20la=20d?= =?UTF-8?q?ocumentaci=C3=B3n?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/conf.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/conf.py b/docs/conf.py index d47fa42..ff9065b 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -111,7 +111,7 @@ # The name for this set of Sphinx documents. If None, it defaults to # " v documentation". -html_title = "Documentación de {} v{}".format(project, release) +html_title = "Documentacion de {} v{}".format(project, release) # A shorter title for the navigation bar. Default is the same as html_title. html_short_title = html_title From 6e32232deef4218e706fdcfd2b6cbb55ae0a0e0e Mon Sep 17 00:00:00 2001 From: Mauricio Baeza Date: Wed, 3 Feb 2016 20:45:15 -0600 Subject: [PATCH 157/167] =?UTF-8?q?Se=20regresa=20a=202011=20como=20a?= =?UTF-8?q?=C3=B1o=20de=20inicio=20para=20descargas?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- admincfdi/values.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/admincfdi/values.py b/admincfdi/values.py index 11839b0..0157b1a 100644 --- a/admincfdi/values.py +++ b/admincfdi/values.py @@ -68,7 +68,7 @@ class Global(object): PESO = ('mxn', 'mxp', 'm.n.', 'p', 'mn', 'pmx', 'mex') DOLAR = ('dólar', 'dólares', 'dolar', 'dolares', 'usd') ICON = os.path.join(PATHS['img'], 'favicon.png') - YEAR_INIT = 2014 + YEAR_INIT = 2011 FIELDS_REPORT = '{UUID}|{serie}|{folio}|{emisor_rfc}|{emisor_nombre}|' \ '{receptor_rfc}|{receptor_nombre}|{fecha}|{FechaTimbrado}|' \ '{tipoDeComprobante}|{Moneda}|{TipoCambio}|{subTotal}|' \ From 93df077931da4da8e90dd1a7587849175cc14ff8 Mon Sep 17 00:00:00 2001 From: Mauricio Baeza Date: Thu, 4 Feb 2016 22:25:44 -0600 Subject: [PATCH 158/167] =?UTF-8?q?Fix=20-=20Ahora=20se=20identifica=20cor?= =?UTF-8?q?rectamente=20tanto=20si=20la=20interfaz=20esta=20en=20ingles=20?= =?UTF-8?q?o=20en=20espa=C3=B1ol?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- admincfdi/pyutil.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/admincfdi/pyutil.py b/admincfdi/pyutil.py index cc1eed6..a29a06a 100644 --- a/admincfdi/pyutil.py +++ b/admincfdi/pyutil.py @@ -39,6 +39,7 @@ from selenium.webdriver.common.by import By from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC +from selenium.common.exceptions import TimeoutException from fpdf import FPDF from admincfdi.values import Global @@ -1796,12 +1797,13 @@ def connect(self, profile, rfc='', ciec=''): txt.send_keys(ciec) txt.submit() wait = WebDriverWait(browser, 10) - wait.until(EC.title_contains('NetIQ Access')) - iframe = browser.find_element(By.ID, 'content') - browser.switch_to.frame(iframe) - wait.until(EC.text_to_be_present_in_element( - (By.CLASS_NAME, 'messagetext'), 'session has been authenticated')) - self.status('Conectado...') + try: + wait.until(EC.title_contains('NetIQ Access')) + self.status('Conectado') + return True + except TimeoutException: + self.status('No conectado') + return False def disconnect(self): 'Cierra la sesión y el navegador' From fd2a66e981308206cc519cf7cddc08777717cdb1 Mon Sep 17 00:00:00 2001 From: Patricio Paez Date: Fri, 5 Feb 2016 18:35:16 -0600 Subject: [PATCH 159/167] Cobertura para DescargaSAT.connect() --- admincfdi/tests/test_pyutil.py | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/admincfdi/tests/test_pyutil.py b/admincfdi/tests/test_pyutil.py index 7c6948d..289000d 100644 --- a/admincfdi/tests/test_pyutil.py +++ b/admincfdi/tests/test_pyutil.py @@ -62,7 +62,8 @@ def setUp(self): pyutil.webdriver.FirefoxProfile = webdriver.FirefoxProfile self.WebDriverWait = pyutil.WebDriverWait - pyutil.WebDriverWait = Mock() + self.wait = Mock() + pyutil.WebDriverWait = Mock(return_value=self.wait) self.sleep = time.sleep time.sleep = Mock() @@ -98,6 +99,19 @@ def test_connect(self): profile = descarga.connect(profile, rfc='x', ciec='y') self.assertEqual(3, self.status.call_count) + def test_connect_fail(self): + from unittest.mock import Mock + from admincfdi.pyutil import DescargaSAT + from admincfdi.pyutil import WebDriverWait + from selenium import webdriver + from selenium.common.exceptions import TimeoutException + + self.wait.until.side_effect = TimeoutException + profile = webdriver.FirefoxProfile() + descarga = DescargaSAT(status_callback=self.status) + profile = descarga.connect(profile, rfc='x', ciec='y') + self.assertRaises(TimeoutException) + def test_disconnect_not_connected(self): from admincfdi.pyutil import DescargaSAT from selenium import webdriver From c3206a3168e02149702f1dca61b72474a5c07fab Mon Sep 17 00:00:00 2001 From: Patricio Paez Date: Fri, 5 Feb 2016 18:43:20 -0600 Subject: [PATCH 160/167] Agregar prueba funcional - Se verifica el valor regresado por DescargaSAT.connect() y se cubre el caso de falla. --- functional_DescargaSAT.py | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/functional_DescargaSAT.py b/functional_DescargaSAT.py index 5cce63c..3f73e8b 100755 --- a/functional_DescargaSAT.py +++ b/functional_DescargaSAT.py @@ -24,9 +24,24 @@ def no_op(*args): pass descarga = DescargaSAT(status_callback=no_op) - descarga.connect(profile, rfc=self.rfc, ciec=self.ciec) + status = descarga.connect(profile, rfc=self.rfc, ciec=self.ciec) + self.assertTrue(status) descarga.disconnect() + def test_connect_fail(self): + from admincfdi.pyutil import DescargaSAT + from selenium import webdriver + + profile = webdriver.FirefoxProfile() + + def no_op(*args): + pass + + descarga = DescargaSAT(status_callback=no_op) + status = descarga.connect(profile, rfc='x', ciec='y') + self.assertFalse(status) + descarga.browser.close() + def test_search_uuid(self): import os import tempfile From 045f383b9637abd5c137b007ee20e5cc80c41d4e Mon Sep 17 00:00:00 2001 From: Patricio Paez Date: Fri, 5 Feb 2016 19:20:07 -0600 Subject: [PATCH 161/167] =?UTF-8?q?Actualizar=20documentaci=C3=B3n?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/devel.rst | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/docs/devel.rst b/docs/devel.rst index 6b0bd45..3700d01 100644 --- a/docs/devel.rst +++ b/docs/devel.rst @@ -54,12 +54,11 @@ Los detalles de cada paso: - Llenar el usuario y la contraseña (RFC y CIEC) - Enviar los datos al servidor - Esperar la respuesta - - El título de la página cambia a *NetIQ Access Manager* - - Hay un elemento iframe con id ``content``, el cual contiene: - - En caso de éxito, el elemento con clase ``messagetext`` - con el texto *session has been authenticated*. - - En caso de falla, un pop up con el elemento con id ``xacerror`` - que contiene el texto *Login failed* + - En caso de éxito, se carga una página con el título + *NetIQ Access Manager* + - En caso de falla, un elemento ``div`` con id ``xacerror`` + deja de estar oculto y muestra su texto que empieza + con *El RFC o contraseña son incorrectos.* #. Buscar From 4d3f9fe1f6942ea4a4840ec70ebae741aee0a41c Mon Sep 17 00:00:00 2001 From: Patricio Paez Date: Fri, 12 Feb 2016 22:27:56 -0600 Subject: [PATCH 162/167] Remover comandos innecesarios --- admin-cfdi | 5 ----- 1 file changed, 5 deletions(-) diff --git a/admin-cfdi b/admin-cfdi index eaf5361..7977490 100755 --- a/admin-cfdi +++ b/admin-cfdi @@ -136,11 +136,6 @@ class Application(pygubu.TkApplication): combo = self._get_object('combo_end_second') self.util.combo_values(combo, minutes, 59) - if not APP_LIBO: - self._config('radio_ods', {'state': 'disabled'}) - self._config('radio_json', {'state': 'disabled'}) - self._config('button_select_template_json', {'state': 'disabled'}) - self._set('mail_port', 993) self._get_object('check_ssl').invoke() self._focus_set('text_rfc') From 766875e0517189d2bd5a63afcb845bb5bf99f9ce Mon Sep 17 00:00:00 2001 From: Patricio Paez Date: Sat, 13 Feb 2016 08:00:13 -0600 Subject: [PATCH 163/167] Soportar hora y minuto final MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Agregar parámetros opcionales hora_final y minuto_final a DescargaSAT.search() - Se descartan ceros a la izquierda, de lo contrario la página del SAT ignora el valor. Se explica en la documentación - Agregar una prueba funcional - Agregar ambos parámetros opcionales a descarga-cfdi - admin-cfdi y descarga-cfdi pasan ambos parámetros a DescargaSAT.search() --- admin-cfdi | 2 ++ admincfdi/pyutil.py | 18 ++++++++++++++++-- descarga-cfdi | 10 ++++++++++ docs/devel.rst | 6 +++++- functional_DescargaSAT.py | 27 +++++++++++++++++++++++++++ 5 files changed, 60 insertions(+), 3 deletions(-) diff --git a/admin-cfdi b/admin-cfdi index 7977490..6993dce 100755 --- a/admin-cfdi +++ b/admin-cfdi @@ -410,6 +410,8 @@ class Application(pygubu.TkApplication): año=data['año'], mes=data['mes'], día=data['día'], + hora_final=data['end_hour'], + minuto_final=data['end_minute'], mes_completo_por_día=data['mes_completo_por_día']) descarga.download(docs) except Exception as e: diff --git a/admincfdi/pyutil.py b/admincfdi/pyutil.py index a29a06a..3552824 100644 --- a/admincfdi/pyutil.py +++ b/admincfdi/pyutil.py @@ -1827,6 +1827,8 @@ def search(self, año=None, mes=None, día='00', + hora_final='23', + minuto_final='59', mes_completo_por_día=False): 'Busca y regresa los resultados' @@ -1858,6 +1860,10 @@ def search(self, (By.ID, self.g.SAT['emisor']))) if rfc_emisor: txt.send_keys(rfc_emisor) + + hora_final = hora_final.lstrip('0') + minuto_final = minuto_final.lstrip('0') + # Emitidas if facturas_emitidas: year = int(año) @@ -1883,11 +1889,11 @@ def search(self, # Hay que seleccionar también la hora, minuto y segundo arg = "document.getElementById('{}')." \ "value={};".format( - self.g.SAT['hour'], '23') + self.g.SAT['hour'], hora_final) browser.execute_script(arg) arg = "document.getElementById('{}')." \ "value={};".format( - self.g.SAT['minute'], '59') + self.g.SAT['minute'], minuto_final) browser.execute_script(arg) arg = "document.getElementById('{}')." \ "value={};".format( @@ -1909,6 +1915,14 @@ def search(self, "value='{}';".format( self.g.SAT['day'], día) browser.execute_script(arg) + arg = "document.getElementById('{}')." \ + "value='{}';".format( + self.g.SAT['end_hour'], hora_final) + browser.execute_script(arg) + arg = "document.getElementById('{}')." \ + "value='{}';".format( + self.g.SAT['end_minute'], minuto_final) + browser.execute_script(arg) results_table = browser.find_element_by_id( self.g.SAT['resultados']) diff --git a/descarga-cfdi b/descarga-cfdi index 483e514..4e5c883 100755 --- a/descarga-cfdi +++ b/descarga-cfdi @@ -78,6 +78,14 @@ def process_command_line_arguments(): parser.add_argument('--día', help=help, default='00') + help = 'Hora final. Por omisión es 23.' + parser.add_argument('--hora-final', + help=help, default='23') + + help = 'Minuto final. Por omisión es 59.' + parser.add_argument('--minuto-final', + help=help, default='59') + help = 'Mes completo por día. Por omisión no se usa en la búsqueda.' parser.add_argument('--mes-completo-por-día', action='store_const', const=True, help=help, default=False) @@ -107,6 +115,8 @@ def main(): año=args.año, mes=args.mes, día=args.día, + hora_final=args.hora_final, + minuto_final=args.minuto_final, mes_completo_por_día=args.mes_completo_por_día) descarga.download(docs) except Exception as e: diff --git a/docs/devel.rst b/docs/devel.rst index 3700d01..d4263c9 100644 --- a/docs/devel.rst +++ b/docs/devel.rst @@ -89,7 +89,11 @@ Los detalles de cada paso: - ``ctl00_MainContent_CldFechaFinal2_DdlMinuto`` - ``ctl00_MainContent_CldFechaFinal2_DdlSegundo`` - las cadenas 23, 59 y 59 + una cadena con un valor en el rango que corresponde + respectivamente: 1 a 23, 1 a 59 y 1 a 59. + No usar ceros a la izquierda para valores menores + a 10: usar 5, no 05. Por omisión hay que llenar cada + select con el valor máximo correspondiente. - Se se buscan facturas recibidas: - Asignar a los selects no visibles con ids diff --git a/functional_DescargaSAT.py b/functional_DescargaSAT.py index 3f73e8b..7a68fd9 100755 --- a/functional_DescargaSAT.py +++ b/functional_DescargaSAT.py @@ -134,6 +134,33 @@ def no_op(*args): descarga.disconnect() self.assertEqual(expected, len(os.listdir(destino))) + def test_hora_minuto_final(self): + import os + import tempfile + from admincfdi.pyutil import DescargaSAT + + def no_op(*args): + pass + + seccion = self.config['hora_minuto_final'] + año = seccion['año'] + mes = seccion['mes'] + día = seccion['día'] + hora_final = seccion['hora_final'] + minuto_final = seccion['minuto_final'] + expected = int(seccion['expected']) + descarga = DescargaSAT(status_callback=no_op, + download_callback=no_op) + with tempfile.TemporaryDirectory() as tempdir: + destino = os.path.join(tempdir, 'cfdi-descarga') + profile = descarga.get_firefox_profile(destino) + descarga.connect(profile, rfc=self.rfc, ciec=self.ciec) + docs = descarga.search(año=año, mes=mes, día=día, + hora_final=hora_final, minuto_final=minuto_final) + descarga.download(docs) + descarga.disconnect() + self.assertEqual(expected, len(os.listdir(destino))) + def test_mes_completo(self): import os import tempfile From 935b28beb4609db9e6a0ed04dc33881f8d68b0e8 Mon Sep 17 00:00:00 2001 From: Patricio Paez Date: Sun, 14 Feb 2016 11:59:27 -0600 Subject: [PATCH 164/167] Soportar hora y minuto inicial MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Agregar parámetros opcionales hora_inicial y minuto_inicial a DescargaSAT.search() - Agregar una prueba funcional - Agregar ambos parámetros opcionales a descarga-cfdi - admin-cfdi y descarga-cfdi pasan ambos parámetros a DescargaSAT.search() --- admin-cfdi | 2 ++ admincfdi/pyutil.py | 12 ++++++++++++ descarga-cfdi | 10 ++++++++++ functional_DescargaSAT.py | 28 ++++++++++++++++++++++++++++ 4 files changed, 52 insertions(+) diff --git a/admin-cfdi b/admin-cfdi index 6993dce..f59b866 100755 --- a/admin-cfdi +++ b/admin-cfdi @@ -410,6 +410,8 @@ class Application(pygubu.TkApplication): año=data['año'], mes=data['mes'], día=data['día'], + hora_inicial=data['start_hour'], + minuto_inicial=data['start_minute'], hora_final=data['end_hour'], minuto_final=data['end_minute'], mes_completo_por_día=data['mes_completo_por_día']) diff --git a/admincfdi/pyutil.py b/admincfdi/pyutil.py index 3552824..e38b9b9 100644 --- a/admincfdi/pyutil.py +++ b/admincfdi/pyutil.py @@ -1827,6 +1827,8 @@ def search(self, año=None, mes=None, día='00', + hora_inicial='0', + minuto_inicial='0', hora_final='23', minuto_final='59', mes_completo_por_día=False): @@ -1861,6 +1863,8 @@ def search(self, if rfc_emisor: txt.send_keys(rfc_emisor) + hora_inicial = hora_inicial.lstrip('0') + minuto_inicial = minuto_inicial.lstrip('0') hora_final = hora_final.lstrip('0') minuto_final = minuto_final.lstrip('0') @@ -1915,6 +1919,14 @@ def search(self, "value='{}';".format( self.g.SAT['day'], día) browser.execute_script(arg) + arg = "document.getElementById('{}')." \ + "value='{}';".format( + self.g.SAT['start_hour'], hora_inicial) + browser.execute_script(arg) + arg = "document.getElementById('{}')." \ + "value='{}';".format( + self.g.SAT['start_minute'], minuto_inicial) + browser.execute_script(arg) arg = "document.getElementById('{}')." \ "value='{}';".format( self.g.SAT['end_hour'], hora_final) diff --git a/descarga-cfdi b/descarga-cfdi index 4e5c883..8e22993 100755 --- a/descarga-cfdi +++ b/descarga-cfdi @@ -78,6 +78,14 @@ def process_command_line_arguments(): parser.add_argument('--día', help=help, default='00') + help = 'Hora inicial. Por omisión es 0.' + parser.add_argument('--hora-inicial', + help=help, default='0') + + help = 'Minuto inicial. Por omisión es 0.' + parser.add_argument('--minuto-inicial', + help=help, default='0') + help = 'Hora final. Por omisión es 23.' parser.add_argument('--hora-final', help=help, default='23') @@ -115,6 +123,8 @@ def main(): año=args.año, mes=args.mes, día=args.día, + hora_inicial=args.hora_inicial, + minuto_inicial=args.minuto_inicial, hora_final=args.hora_final, minuto_final=args.minuto_final, mes_completo_por_día=args.mes_completo_por_día) diff --git a/functional_DescargaSAT.py b/functional_DescargaSAT.py index 7a68fd9..c3f1be8 100755 --- a/functional_DescargaSAT.py +++ b/functional_DescargaSAT.py @@ -134,6 +134,34 @@ def no_op(*args): descarga.disconnect() self.assertEqual(expected, len(os.listdir(destino))) + def test_hora_minuto_inicial(self): + import os + import tempfile + from admincfdi.pyutil import DescargaSAT + + def no_op(*args): + pass + + seccion = self.config['hora_minuto_inicial'] + año = seccion['año'] + mes = seccion['mes'] + día = seccion['día'] + hora_inicial = seccion['hora_inicial'] + minuto_inicial = seccion['minuto_inicial'] + expected = int(seccion['expected']) + descarga = DescargaSAT(status_callback=no_op, + download_callback=no_op) + with tempfile.TemporaryDirectory() as tempdir: + destino = os.path.join(tempdir, 'cfdi-descarga') + profile = descarga.get_firefox_profile(destino) + descarga.connect(profile, rfc=self.rfc, ciec=self.ciec) + docs = descarga.search(año=año, mes=mes, día=día, + hora_inicial=hora_inicial, + minuto_inicial=minuto_inicial) + descarga.download(docs) + descarga.disconnect() + self.assertEqual(expected, len(os.listdir(destino))) + def test_hora_minuto_final(self): import os import tempfile From cae699f1f5f8c02d1167232244a488442ea1f037 Mon Sep 17 00:00:00 2001 From: Patricio Paez Date: Sun, 14 Feb 2016 13:12:22 -0600 Subject: [PATCH 165/167] Soportar segundo inicial y final MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Agregar parámetros opcionales segundo_inicial y segundo_final a DescargaSAT.search() - Agregar una prueba funcional - Agregar ambos parámetros opcionales a descarga-cfdi - admin-cfdi y descarga-cfdi pasan ambos parámetros a DescargaSAT.search() --- admin-cfdi | 2 ++ admincfdi/pyutil.py | 14 +++++++++++++- descarga-cfdi | 10 ++++++++++ functional_DescargaSAT.py | 34 ++++++++++++++++++++++++++++++++++ 4 files changed, 59 insertions(+), 1 deletion(-) diff --git a/admin-cfdi b/admin-cfdi index f59b866..8552d75 100755 --- a/admin-cfdi +++ b/admin-cfdi @@ -412,8 +412,10 @@ class Application(pygubu.TkApplication): día=data['día'], hora_inicial=data['start_hour'], minuto_inicial=data['start_minute'], + segundo_inicial=data['start_second'], hora_final=data['end_hour'], minuto_final=data['end_minute'], + segundo_final=data['end_second'], mes_completo_por_día=data['mes_completo_por_día']) descarga.download(docs) except Exception as e: diff --git a/admincfdi/pyutil.py b/admincfdi/pyutil.py index e38b9b9..1a975a9 100644 --- a/admincfdi/pyutil.py +++ b/admincfdi/pyutil.py @@ -1829,8 +1829,10 @@ def search(self, día='00', hora_inicial='0', minuto_inicial='0', + segundo_inicial='0', hora_final='23', minuto_final='59', + segundo_final='59', mes_completo_por_día=False): 'Busca y regresa los resultados' @@ -1865,8 +1867,10 @@ def search(self, hora_inicial = hora_inicial.lstrip('0') minuto_inicial = minuto_inicial.lstrip('0') + segundo_inicial = segundo_inicial.lstrip('0') hora_final = hora_final.lstrip('0') minuto_final = minuto_final.lstrip('0') + segundo_final = segundo_final.lstrip('0') # Emitidas if facturas_emitidas: @@ -1901,7 +1905,7 @@ def search(self, browser.execute_script(arg) arg = "document.getElementById('{}')." \ "value={};".format( - self.g.SAT['second'], '59') + self.g.SAT['second'], segundo_final) browser.execute_script(arg) if mes_completo_por_día: return self._download_sat_month_emitidas(dates[1], day) @@ -1927,6 +1931,10 @@ def search(self, "value='{}';".format( self.g.SAT['start_minute'], minuto_inicial) browser.execute_script(arg) + arg = "document.getElementById('{}')." \ + "value='{}';".format( + self.g.SAT['start_second'], segundo_inicial) + browser.execute_script(arg) arg = "document.getElementById('{}')." \ "value='{}';".format( self.g.SAT['end_hour'], hora_final) @@ -1935,6 +1943,10 @@ def search(self, "value='{}';".format( self.g.SAT['end_minute'], minuto_final) browser.execute_script(arg) + arg = "document.getElementById('{}')." \ + "value='{}';".format( + self.g.SAT['end_second'], segundo_final) + browser.execute_script(arg) results_table = browser.find_element_by_id( self.g.SAT['resultados']) diff --git a/descarga-cfdi b/descarga-cfdi index 8e22993..5b8eabe 100755 --- a/descarga-cfdi +++ b/descarga-cfdi @@ -86,6 +86,10 @@ def process_command_line_arguments(): parser.add_argument('--minuto-inicial', help=help, default='0') + help = 'Segundo inicial. Por omisión es 0.' + parser.add_argument('--segundo-inicial', + help=help, default='0') + help = 'Hora final. Por omisión es 23.' parser.add_argument('--hora-final', help=help, default='23') @@ -94,6 +98,10 @@ def process_command_line_arguments(): parser.add_argument('--minuto-final', help=help, default='59') + help = 'Segundo final. Por omisión es 59.' + parser.add_argument('--segundo-final', + help=help, default='59') + help = 'Mes completo por día. Por omisión no se usa en la búsqueda.' parser.add_argument('--mes-completo-por-día', action='store_const', const=True, help=help, default=False) @@ -125,8 +133,10 @@ def main(): día=args.día, hora_inicial=args.hora_inicial, minuto_inicial=args.minuto_inicial, + segundo_inicial=args.segundo_inicial, hora_final=args.hora_final, minuto_final=args.minuto_final, + segundo_final=args.segundo_final, mes_completo_por_día=args.mes_completo_por_día) descarga.download(docs) except Exception as e: diff --git a/functional_DescargaSAT.py b/functional_DescargaSAT.py index c3f1be8..8273567 100755 --- a/functional_DescargaSAT.py +++ b/functional_DescargaSAT.py @@ -189,6 +189,40 @@ def no_op(*args): descarga.disconnect() self.assertEqual(expected, len(os.listdir(destino))) + def test_segundo_inicial_final(self): + import os + import tempfile + from admincfdi.pyutil import DescargaSAT + + def no_op(*args): + pass + + seccion = self.config['segundo_inicial_final'] + año = seccion['año'] + mes = seccion['mes'] + día = seccion['día'] + hora_inicial = seccion['hora_inicial'] + minuto_inicial = seccion['minuto_inicial'] + segundo_inicial = seccion['segundo_inicial'] + hora_final = seccion['hora_final'] + minuto_final = seccion['minuto_final'] + segundo_final = seccion['segundo_final'] + expected = int(seccion['expected']) + descarga = DescargaSAT(status_callback=no_op, + download_callback=no_op) + with tempfile.TemporaryDirectory() as tempdir: + destino = os.path.join(tempdir, 'cfdi-descarga') + profile = descarga.get_firefox_profile(destino) + descarga.connect(profile, rfc=self.rfc, ciec=self.ciec) + docs = descarga.search(año=año, mes=mes, día=día, + hora_inicial=hora_inicial, minuto_inicial=minuto_inicial, + segundo_inicial=segundo_inicial, + hora_final=hora_final, minuto_final=minuto_final, + segundo_final=segundo_final) + descarga.download(docs) + descarga.disconnect() + self.assertEqual(expected, len(os.listdir(destino))) + def test_mes_completo(self): import os import tempfile From d2c81c8d292b19a361714774c5e3a566c97361fe Mon Sep 17 00:00:00 2001 From: Patricio Paez Date: Sun, 14 Feb 2016 13:54:52 -0600 Subject: [PATCH 166/167] =?UTF-8?q?Actualizar=20la=20documentaci=C3=B3n?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/devel.rst | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/docs/devel.rst b/docs/devel.rst index d4263c9..c2c89fb 100644 --- a/docs/devel.rst +++ b/docs/devel.rst @@ -100,8 +100,21 @@ Los detalles de cada paso: - ``DdlAnio`` - ``ctl00_MainContent_CldFecha_DdlMes`` - ``ctl00_MainContent_CldFecha_DdlDia`` + - ``ctl00_MainContent_CldFecha_DdlHora`` + - ``ctl00_MainContent_CldFecha_DdlMinuto`` + - ``ctl00_MainContent_CldFecha_DdlSegundo`` + - ``ctl00_MainContent_CldFecha_DdlHoraFin`` + - ``ctl00_MainContent_CldFecha_DdlMinutoFin`` + - ``ctl00_MainContent_CldFecha_DdlSegundoFin`` + + los valores de los parámetros año, mes, día, + hora_inicial, minuto_inicial, segundo_inicial, + hora_final, minuto_final y segundo_final respectivamente. + Los valores de horas, minutos y segundos + no deben llevar 0 a la izquierda. El valor de + día requiere 0 a la izquierda para valores menores + a 10. - los valores de los parámetros año, mes y día - Enviar la forma de búsqueda al servidor mediante método POST, los datos que se envían se muestran más bajo. From dc3cfaf0d93e2e1e3da69cb029b76642f9ef985c Mon Sep 17 00:00:00 2001 From: Patricio Paez Date: Sun, 14 Feb 2016 17:01:47 -0600 Subject: [PATCH 167/167] =?UTF-8?q?Correci=C3=B3n=20al=20remover=20ceros?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - El método anterior convierte '0' en '' - Cubrir con prueba unitaria --- admincfdi/pyutil.py | 12 ++++++------ admincfdi/tests/test_pyutil.py | 13 +++++++++++++ 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/admincfdi/pyutil.py b/admincfdi/pyutil.py index 1a975a9..841794f 100644 --- a/admincfdi/pyutil.py +++ b/admincfdi/pyutil.py @@ -1865,12 +1865,12 @@ def search(self, if rfc_emisor: txt.send_keys(rfc_emisor) - hora_inicial = hora_inicial.lstrip('0') - minuto_inicial = minuto_inicial.lstrip('0') - segundo_inicial = segundo_inicial.lstrip('0') - hora_final = hora_final.lstrip('0') - minuto_final = minuto_final.lstrip('0') - segundo_final = segundo_final.lstrip('0') + hora_inicial = int(hora_inicial) + minuto_inicial = int(minuto_inicial) + segundo_inicial = int(segundo_inicial) + hora_final = int(hora_final) + minuto_final = int(minuto_final) + segundo_final = int(segundo_final) # Emitidas if facturas_emitidas: diff --git a/admincfdi/tests/test_pyutil.py b/admincfdi/tests/test_pyutil.py index 289000d..5962e5b 100644 --- a/admincfdi/tests/test_pyutil.py +++ b/admincfdi/tests/test_pyutil.py @@ -220,6 +220,19 @@ def test_search_mes_eq_día(self): results = descarga.search(día='01', mes='01') self.assertEqual(0, len(results)) + def test_search_minuto_final_cero(self): + from unittest.mock import MagicMock + from admincfdi.pyutil import DescargaSAT + from selenium import webdriver + + profile = webdriver.FirefoxProfile() + descarga = DescargaSAT(status_callback=self.status) + descarga.browser = MagicMock() + results = descarga.search(día='01', mes='02', minuto_final='0') + + expected = "document.getElementById('ctl00_MainContent_CldFecha_DdlMinutoFin').value='0';" + descarga.browser.execute_script.assert_any_call(expected) + def test_search_mes_completo_por_día(self): from unittest.mock import MagicMock, Mock from admincfdi.pyutil import DescargaSAT