pynotas 0.12.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pynotas/__init__.py +42 -0
- pynotas/__main__.py +2 -0
- pynotas/_version.py +1 -0
- pynotas/analysis_context.py +47 -0
- pynotas/analyzer.py +173 -0
- pynotas/artwork.py +223 -0
- pynotas/capture.py +81 -0
- pynotas/cli.py +22 -0
- pynotas/exports.py +109 -0
- pynotas/files.py +32 -0
- pynotas/fonts/DejaVuSans-Bold.ttf +0 -0
- pynotas/fonts/DejaVuSans.ttf +0 -0
- pynotas/fonts/DejaVuSansMono.ttf +0 -0
- pynotas/fonts/LICENSE_DEJAVU +99 -0
- pynotas/illustrations.py +61 -0
- pynotas/infographic.py +208 -0
- pynotas/layout.py +354 -0
- pynotas/lessons.py +145 -0
- pynotas/letter_layout.py +143 -0
- pynotas/ml.py +219 -0
- pynotas/model.py +131 -0
- pynotas/notebook.py +214 -0
- pynotas/preparation.py +157 -0
- pynotas/presentation.py +42 -0
- pynotas/reading_layout.py +164 -0
- pynotas/renderers.py +232 -0
- pynotas/resources.py +122 -0
- pynotas/selector.py +73 -0
- pynotas/sheets.py +109 -0
- pynotas/stages.py +163 -0
- pynotas/syntax.py +24 -0
- pynotas/teacher.py +234 -0
- pynotas/teaching.py +318 -0
- pynotas/theory.py +200 -0
- pynotas/walkthrough.py +413 -0
- pynotas-0.12.0.dist-info/METADATA +110 -0
- pynotas-0.12.0.dist-info/RECORD +41 -0
- pynotas-0.12.0.dist-info/WHEEL +5 -0
- pynotas-0.12.0.dist-info/entry_points.txt +2 -0
- pynotas-0.12.0.dist-info/licenses/LICENSE +21 -0
- pynotas-0.12.0.dist-info/top_level.txt +1 -0
pynotas/__init__.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""PyNotas: código → modelo → representación → SVG/HTML."""
|
|
2
|
+
from .analyzer import analyze as analizar
|
|
3
|
+
from .selector import select as seleccionar
|
|
4
|
+
from .renderers import render as renderizar
|
|
5
|
+
from .notebook import activate as activar, deactivate as desactivar, diagnostics as diagnostico
|
|
6
|
+
|
|
7
|
+
from ._version import __version__
|
|
8
|
+
__all__ = ["analizar", "seleccionar", "renderizar", "generar", "mostrar", "activar", "desactivar", "diagnostico", "guardar", "explicar_archivo", "exportar"]
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def generar(codigo, formato="auto", nivel="inicial",
|
|
12
|
+
objetivo="Comprender cómo los datos se transforman en un resultado",
|
|
13
|
+
contexto="auto", oculto=False, descargas=True):
|
|
14
|
+
"""Devuelve HTML autocontenido. No ejecuta codigo."""
|
|
15
|
+
model = analizar(codigo, objetivo, nivel, context=contexto)
|
|
16
|
+
return renderizar(model, seleccionar(model, formato), collapsed=oculto, downloads=descargas)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def mostrar(codigo, **opciones):
|
|
20
|
+
"""Muestra una lámina estática sin ejecutar codigo."""
|
|
21
|
+
try:
|
|
22
|
+
from IPython.display import HTML, display
|
|
23
|
+
except ImportError as error:
|
|
24
|
+
raise RuntimeError("mostrar() necesita IPython. Instala pynotas[notebook] o usa guardar().") from error
|
|
25
|
+
display(HTML(generar(codigo, **opciones)))
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def load_ipython_extension(ipython):
|
|
29
|
+
activar(shell=ipython)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def unload_ipython_extension(ipython):
|
|
33
|
+
desactivar(shell=ipython)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
from .files import guardar, explicar_archivo
|
|
37
|
+
from .exports import exportar
|
|
38
|
+
|
|
39
|
+
# Importing in a notebook starts the existing post-run observer. No user code
|
|
40
|
+
# is executed here; normal Python imports do not create an interactive shell.
|
|
41
|
+
from .notebook import auto_activate as _auto_activate
|
|
42
|
+
_auto_activate()
|
pynotas/__main__.py
ADDED
pynotas/_version.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.12.0"
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Reviewed analytical purpose, principle and interpretation; no runtime values."""
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
from .lessons import LESSONS
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@dataclass(frozen=True)
|
|
7
|
+
class AnalysisContext:
|
|
8
|
+
title: str
|
|
9
|
+
purpose: str
|
|
10
|
+
principle: str
|
|
11
|
+
interpretation: str
|
|
12
|
+
formula: str = ''
|
|
13
|
+
source: str = ''
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
RMS_SOURCE='https://www.ifm.com/ca/en/shared/technologies/real-time-maintenance/technology/time-domain'
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def for_model(model,representation):
|
|
20
|
+
keys={t.key for t in model.theories}
|
|
21
|
+
if 'rms' in keys:
|
|
22
|
+
return AnalysisContext('RMS · medir la magnitud de una señal',
|
|
23
|
+
'Resumir la magnitud global de una señal real. En vibraciones ayuda a seguir cambios en el nivel de un registro.',
|
|
24
|
+
'Los signos pueden cancelarse al promediar. Elevar al cuadrado evita esa cancelación; promediar resume y la raíz recupera las unidades.',
|
|
25
|
+
'Un RMS mayor indica mayor magnitud en el registro. Compara iguales unidades, banda y condiciones. Por sí solo no identifica una falla.',
|
|
26
|
+
'RMS = √[(x₁² + … + xₙ²) / N] x: muestras reales · N: cantidad de muestras',RMS_SOURCE)
|
|
27
|
+
if 'fft' in keys:
|
|
28
|
+
return AnalysisContext('FFT · reconocer componentes de frecuencia',
|
|
29
|
+
'Pasar de cómo cambia una señal en el tiempo a qué componentes de frecuencia contiene.',
|
|
30
|
+
'La transformada expresa la señal muestreada como componentes sinusoidales. La frecuencia de muestreo permite construir el eje en Hz.',
|
|
31
|
+
'Un pico señala una componente destacada. Revisa escalado, ventana y muestreo antes de interpretar amplitudes; un pico no confirma una falla.',
|
|
32
|
+
'Separación entre frecuencias = fs / N · Límite de Nyquist = fs / 2',
|
|
33
|
+
'https://numpy.org/doc/stable/reference/routines.fft.html')
|
|
34
|
+
if model.case:
|
|
35
|
+
vibration=('En vibraciones, ' if model.context=='vibraciones' else 'Si los datos son vibraciones, ')
|
|
36
|
+
return AnalysisContext('Archivo MATLAB · preparar los datos para analizarlos',
|
|
37
|
+
'Identificar qué variables hay y cómo están organizadas antes de elegir los datos del análisis.',
|
|
38
|
+
'Un .mat guarda variables. loadmat las organiza por nombre en un diccionario; type indica su tipo y shape, sus dimensiones.',
|
|
39
|
+
vibration+'identifica canal, eje de muestras, unidades y frecuencia de muestreo. Cargar el archivo todavía no calcula RMS ni FFT.',
|
|
40
|
+
source='https://docs.scipy.org/doc/scipy/reference/generated/scipy.io.loadmat.html')
|
|
41
|
+
if model.theories:
|
|
42
|
+
theory=model.theories[0]
|
|
43
|
+
return AnalysisContext(theory.title,theory.idea,' → '.join(theory.steps)+'.',theory.meaning,source=theory.source)
|
|
44
|
+
lesson=LESSONS.get(representation.lesson_key,LESSONS['generic'])
|
|
45
|
+
return AnalysisContext(lesson.title,lesson.idea,
|
|
46
|
+
(' → '.join(s for s in (lesson.incoming,lesson.action,lesson.outgoing) if s)+'.') if lesson.incoming else 'Importar permite usar herramientas; no realiza todavía el análisis.',
|
|
47
|
+
lesson.meaning,source=lesson.source)
|
pynotas/analyzer.py
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""Lee sintaxis exclusivamente: no evalúa, ejecuta ni inspecciona variables."""
|
|
2
|
+
import ast
|
|
3
|
+
from .model import Block, Expression, PedagogicalModel, Section
|
|
4
|
+
from .resources import describe_import, enrich_setup
|
|
5
|
+
from .teaching import explain, enrich_sequence
|
|
6
|
+
|
|
7
|
+
OPS = {ast.Add: "Suma (+)", ast.Sub: "Resta (−)", ast.Mult: "Producto (×)",
|
|
8
|
+
ast.Div: "División (/)", ast.FloorDiv: "División entera (//)",
|
|
9
|
+
ast.Mod: "Resto (%)", ast.Pow: "Potencia (**)"}
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def analyze(source, objective="Comprender cómo los datos se transforman en un resultado",
|
|
13
|
+
level="inicial", context="auto", bindings=None):
|
|
14
|
+
if not isinstance(source, str):
|
|
15
|
+
raise TypeError("El código debe ser texto.")
|
|
16
|
+
if not isinstance(objective, str):
|
|
17
|
+
raise TypeError("El objetivo debe ser texto.")
|
|
18
|
+
if level not in {"inicial", "intermedio", "avanzado"}:
|
|
19
|
+
raise ValueError("Nivel válido: inicial, intermedio, avanzado.")
|
|
20
|
+
if len(source) > 60000:
|
|
21
|
+
raise ValueError("La lámina admite hasta 60 000 caracteres por celda.")
|
|
22
|
+
tree = ast.parse(source)
|
|
23
|
+
if sum(1 for _ in ast.walk(tree)) > 1500:
|
|
24
|
+
raise ValueError("Celda demasiado extensa: divídela en pasos pedagógicos.")
|
|
25
|
+
notes = []
|
|
26
|
+
counter = 0
|
|
27
|
+
|
|
28
|
+
def segment(node):
|
|
29
|
+
return ast.get_source_segment(source, node) or ""
|
|
30
|
+
|
|
31
|
+
def expr(node, depth=0):
|
|
32
|
+
children = ()
|
|
33
|
+
label = "Expresión"
|
|
34
|
+
if isinstance(node, ast.BinOp):
|
|
35
|
+
label = OPS.get(type(node.op), "Operación " + type(node.op).__name__)
|
|
36
|
+
if isinstance(node.op, ast.Add):
|
|
37
|
+
label = "Unir listas (+)" if any(isinstance(n, ast.List) for n in (node.left, node.right)) else "Combinar con +"
|
|
38
|
+
if depth < 4:
|
|
39
|
+
children = (expr(node.left, depth + 1), expr(node.right, depth + 1))
|
|
40
|
+
elif isinstance(node, ast.Name):
|
|
41
|
+
label = "Dato que usaremos"
|
|
42
|
+
elif isinstance(node, ast.Constant):
|
|
43
|
+
label = "Valor escrito"
|
|
44
|
+
elif isinstance(node, ast.Call):
|
|
45
|
+
label = "Resultado de una función"
|
|
46
|
+
return Expression(segment(node), label, node.lineno, node.end_lineno, children)
|
|
47
|
+
|
|
48
|
+
def block(node):
|
|
49
|
+
nonlocal counter
|
|
50
|
+
counter += 1
|
|
51
|
+
if counter > 60:
|
|
52
|
+
raise ValueError("La lámina admite hasta 60 instrucciones; divide el código.")
|
|
53
|
+
ident = f"b{counter}"
|
|
54
|
+
code = segment(node)
|
|
55
|
+
kind, title = "opaque", "Bloque de código"
|
|
56
|
+
explanation = "Estructura agrupada; sus pasos internos no se representan en esta versión."
|
|
57
|
+
body, alternative, expression = (), (), None
|
|
58
|
+
reads, writes = (), ()
|
|
59
|
+
summaries = ()
|
|
60
|
+
resources = ()
|
|
61
|
+
sections = []
|
|
62
|
+
if isinstance(node, ast.If):
|
|
63
|
+
kind, title = "decision", "Evaluar condición"
|
|
64
|
+
code = segment(node.test)
|
|
65
|
+
explanation = "La condición selecciona una alternativa. No se observa qué rama se ejecutó."
|
|
66
|
+
body = tuple(block(n) for n in node.body)
|
|
67
|
+
alternative = tuple(block(n) for n in node.orelse)
|
|
68
|
+
elif isinstance(node, (ast.Assign, ast.AnnAssign, ast.AugAssign)):
|
|
69
|
+
value = node.value
|
|
70
|
+
targets = node.targets if isinstance(node, ast.Assign) else [node.target]
|
|
71
|
+
simple = all(isinstance(t, ast.Name) for t in targets)
|
|
72
|
+
if simple and value is not None:
|
|
73
|
+
kind, title = "transform", "Obtener y asignar"
|
|
74
|
+
if isinstance(value, (ast.Constant, ast.List, ast.Tuple, ast.Dict, ast.Set)):
|
|
75
|
+
kind, title = "data", "Definir datos"
|
|
76
|
+
if isinstance(value, ast.BinOp):
|
|
77
|
+
kind, title, expression = "operation", "Combinar expresiones", expr(value)
|
|
78
|
+
reads = tuple(sorted({n.id for n in ast.walk(value)
|
|
79
|
+
if isinstance(n, ast.Name) and isinstance(n.ctx, ast.Load)}))
|
|
80
|
+
writes = tuple(t.id for t in targets)
|
|
81
|
+
if isinstance(node, ast.AugAssign):
|
|
82
|
+
reads = tuple(sorted(set(reads) | set(writes)))
|
|
83
|
+
# Scope-changing expressions cannot support simple name dependencies.
|
|
84
|
+
if any(isinstance(n, (ast.NamedExpr, ast.Lambda, ast.ListComp,
|
|
85
|
+
ast.SetComp, ast.DictComp, ast.GeneratorExp))
|
|
86
|
+
for n in ast.walk(value)):
|
|
87
|
+
kind = "opaque"
|
|
88
|
+
explanation = "Relaciona el destino con la expresión de la derecha; no se calcula su valor."
|
|
89
|
+
elif isinstance(node, ast.AnnAssign) and value is None:
|
|
90
|
+
kind, title = "opaque", "Anotar un nombre"
|
|
91
|
+
explanation = "Declara una anotación de tipo sin asignar un valor."
|
|
92
|
+
elif isinstance(node, ast.Expr):
|
|
93
|
+
kind, title = "call", "Evaluar expresión"
|
|
94
|
+
explanation = "Expresión tal como aparece en el código; no se vuelve a evaluar."
|
|
95
|
+
if isinstance(node.value, ast.Call):
|
|
96
|
+
title = "Llamar una operación"
|
|
97
|
+
if isinstance(node.value.func, ast.Name) and node.value.func.id == "print":
|
|
98
|
+
title = "Mostrar información"
|
|
99
|
+
explanation = "Llama al nombre print. La salida original permanece en el notebook."
|
|
100
|
+
reads = tuple(sorted({n.id for n in ast.walk(node)
|
|
101
|
+
if isinstance(n, ast.Name) and isinstance(n.ctx, ast.Load)}))
|
|
102
|
+
if any(isinstance(n, (ast.NamedExpr, ast.Lambda, ast.ListComp,
|
|
103
|
+
ast.SetComp, ast.DictComp, ast.GeneratorExp)) for n in ast.walk(node)):
|
|
104
|
+
kind = "opaque"
|
|
105
|
+
elif isinstance(node, (ast.Import, ast.ImportFrom)):
|
|
106
|
+
kind = "import"
|
|
107
|
+
title, summaries, explanation, resources = describe_import(node)
|
|
108
|
+
elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
|
|
109
|
+
title = "Definir " + node.name
|
|
110
|
+
explanation = "Definición agrupada. Su cuerpo no se presenta como si se hubiera ejecutado."
|
|
111
|
+
elif isinstance(node, (ast.For, ast.AsyncFor, ast.While)):
|
|
112
|
+
title = "Repetición · bloque agrupado"
|
|
113
|
+
elif isinstance(node, ast.Raise):
|
|
114
|
+
kind, title = "stop", "Lanzar excepción"
|
|
115
|
+
explanation = "Interrumpe este camino mediante una excepción."
|
|
116
|
+
# Nested scopes are explained in foldouts, not attached as executed paths.
|
|
117
|
+
def section(label, statements):
|
|
118
|
+
if statements:
|
|
119
|
+
sections.append(Section(label, enrich_sequence(tuple(block(n) for n in statements), statements)))
|
|
120
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
121
|
+
section("Dentro de la función · se utiliza mediante una llamada", node.body)
|
|
122
|
+
elif isinstance(node, ast.ClassDef):
|
|
123
|
+
section("Dentro de la definición de clase", node.body)
|
|
124
|
+
elif isinstance(node, (ast.For, ast.AsyncFor, ast.While)):
|
|
125
|
+
section("Dentro de la repetición", node.body)
|
|
126
|
+
section("Al terminar sin break", node.orelse)
|
|
127
|
+
elif isinstance(node, (ast.With, ast.AsyncWith)):
|
|
128
|
+
section("Dentro del contexto", node.body)
|
|
129
|
+
elif isinstance(node, (ast.Try, getattr(ast, 'TryStar', ast.Try))):
|
|
130
|
+
section("Instrucciones que se intentan", node.body)
|
|
131
|
+
for handler in node.handlers:
|
|
132
|
+
section("Respuesta al error: " + (ast.unparse(handler.type) if handler.type else "cualquier excepción"), handler.body)
|
|
133
|
+
section("Si termina sin excepción", node.orelse)
|
|
134
|
+
section("Al salir de este intento (finally)", node.finalbody)
|
|
135
|
+
elif isinstance(node, ast.Match):
|
|
136
|
+
for case in node.cases:
|
|
137
|
+
label = "Caso: " + ast.unparse(case.pattern)
|
|
138
|
+
if case.guard: label += " · si " + ast.unparse(case.guard)
|
|
139
|
+
section(label, case.body)
|
|
140
|
+
if kind == "opaque":
|
|
141
|
+
notes.append(f"Línea {node.lineno}: ver el código para seguir todos sus pasos.")
|
|
142
|
+
return explain(Block(ident, kind, title, code, node.lineno, node.end_lineno,
|
|
143
|
+
explanation, reads, writes, body, alternative, expression, summaries, resources, sections=tuple(sections),
|
|
144
|
+
column=node.col_offset,end_column=node.end_col_offset), node)
|
|
145
|
+
|
|
146
|
+
blocks = tuple(block(node) for node in tree.body)
|
|
147
|
+
blocks = enrich_sequence(blocks, tree.body)
|
|
148
|
+
blocks = enrich_setup(blocks, tree.body, source)
|
|
149
|
+
if len(list(walk_blocks(blocks))) > 60:
|
|
150
|
+
raise ValueError("La lámina admite hasta 60 bloques; divide la celda.")
|
|
151
|
+
from .theory import enrich
|
|
152
|
+
blocks, theories = enrich(blocks, tree.body, context, bindings)
|
|
153
|
+
model = PedagogicalModel(source, blocks, objective, level, tuple(notes), context=context, theories=theories)
|
|
154
|
+
from .walkthrough import enrich_reading
|
|
155
|
+
from .stages import enrich_stages
|
|
156
|
+
return enrich_stages(enrich_reading(model, tree, bindings), tree, bindings)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def walk_blocks(blocks):
|
|
160
|
+
for block in blocks:
|
|
161
|
+
yield block
|
|
162
|
+
yield from walk_blocks(block.body)
|
|
163
|
+
yield from walk_blocks(block.alternative)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def walk_details(blocks):
|
|
167
|
+
"""Walk all source instructions, including deferred/compound scopes, for detail only."""
|
|
168
|
+
for b in blocks:
|
|
169
|
+
yield b
|
|
170
|
+
yield from walk_details(b.body)
|
|
171
|
+
yield from walk_details(b.alternative)
|
|
172
|
+
for section in b.sections:
|
|
173
|
+
yield from walk_details(section.blocks)
|
pynotas/artwork.py
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
"""Automatic vector compositions selected from the pedagogical model.
|
|
2
|
+
|
|
3
|
+
All diagrams are conceptual. This module never evaluates code, reads notebook
|
|
4
|
+
variables, or reconstructs results. SVG and PDF consume the same primitives.
|
|
5
|
+
"""
|
|
6
|
+
import math
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True)
|
|
11
|
+
class Theme:
|
|
12
|
+
kind: str
|
|
13
|
+
label: str
|
|
14
|
+
accent: str
|
|
15
|
+
light: str
|
|
16
|
+
bright: str
|
|
17
|
+
secondary: str = '#FAAA72'
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
THEMES = {
|
|
21
|
+
'signal': Theme('signal', 'SEÑALES Y MAGNITUDES', '#007B74', '#E2F2E9', '#78E8CB'),
|
|
22
|
+
'network': Theme('network', 'REDES NEURONALES', '#6451BF', '#EEE9FB', '#C9B3FF'),
|
|
23
|
+
'learning': Theme('learning', 'APRENDIZAJE CON DATOS', '#2857AB', '#E8EEFB', '#94C5FF'),
|
|
24
|
+
'decision': Theme('decision', 'DECISIONES EN PYTHON', '#A94825', '#FBEBDC', '#FFBE8B'),
|
|
25
|
+
'expression': Theme('expression', 'DATOS Y OPERACIONES', '#18787B', '#E3F2EF', '#79DDD7'),
|
|
26
|
+
'resources': Theme('resources', 'HERRAMIENTAS DE PYTHON', '#476199', '#E9EEFA', '#AAC4FF'),
|
|
27
|
+
'code': Theme('code', 'LEER Y COMPRENDER PYTHON', '#18787B', '#E3F2EF', '#79DDD7'),
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def theme_for(model, representation):
|
|
32
|
+
from .illustrations import kind_for
|
|
33
|
+
from .model import is_preparation
|
|
34
|
+
if representation.format == 'decisiones':
|
|
35
|
+
return THEMES['decision']
|
|
36
|
+
if is_preparation(model):
|
|
37
|
+
return THEMES['resources']
|
|
38
|
+
kind = kind_for(tuple(t.key for t in model.theories))
|
|
39
|
+
if kind == 'code' and representation.format == 'despiece':
|
|
40
|
+
kind = 'expression'
|
|
41
|
+
return THEMES[kind]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def wave(p, x, y, w, h, color, squared=False):
|
|
45
|
+
"""A schematic oscillation, with no measured coordinates or values."""
|
|
46
|
+
points = []
|
|
47
|
+
for i in range(81):
|
|
48
|
+
value = math.sin(i / 80 * math.pi * 4)
|
|
49
|
+
if squared:
|
|
50
|
+
value = value * value
|
|
51
|
+
points.append((x + i * w / 80, y - h * value))
|
|
52
|
+
p.line(x, y, x+w, y, '#47606A', .6)
|
|
53
|
+
for a, b in zip(points, points[1:]):
|
|
54
|
+
p.line(*a, *b, color, 1.65)
|
|
55
|
+
for i in (7, 29, 49, 72):
|
|
56
|
+
p.circle(*points[i], 2.1, '#FAAA72')
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def connector(p, x, y, x2, y2, color):
|
|
60
|
+
dx = (x2-x) * .52
|
|
61
|
+
p.path([('M', x, y), ('C', x+dx, y, x2-dx, y2, x2, y2)], stroke=color, width=1)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def icon(p, cx, cy, kind, color, size=19):
|
|
65
|
+
if kind == 'table':
|
|
66
|
+
for row in range(3):
|
|
67
|
+
for col in range(3):
|
|
68
|
+
p.rect(cx-size+col*14, cy-size+row*13, 10, 9,
|
|
69
|
+
color if row != 2 else '#FAAA72', 2)
|
|
70
|
+
elif kind == 'braces':
|
|
71
|
+
p.text(cx-23, cy+9, '{ }', 24, color, 'PyNotasMono')
|
|
72
|
+
elif kind == 'bars':
|
|
73
|
+
for i, height in enumerate((14, 30, 21, 39, 17)):
|
|
74
|
+
p.rect(cx-27+i*12, cy+20-height, 7, height, color if i != 3 else '#FAAA72', 2)
|
|
75
|
+
elif kind == 'dots':
|
|
76
|
+
for i, (dx, dy) in enumerate(((-16,-12),(-5,4),(9,-17),(20,10),(-20,18),(4,21))):
|
|
77
|
+
p.circle(cx+dx, cy+dy, 4, color if i < 4 else '#FAAA72')
|
|
78
|
+
else:
|
|
79
|
+
p.circle(cx, cy, 18, color)
|
|
80
|
+
p.circle(cx, cy, 8, '#153944')
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _label(p, x, y, text, width, color, size=8.1):
|
|
84
|
+
# Page.paragraph preserves every label, wrapping inside its allocated space.
|
|
85
|
+
p.paragraph(x, y, text, width, size, color, step=max(11,size*1.3))
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def draw(p, x, y, w, theme, keys=(), steps=(), resources=(), decision=None):
|
|
89
|
+
"""Full-width teaching canvas. Returns height for the pagination engine."""
|
|
90
|
+
kind = theme.kind
|
|
91
|
+
h = 140 if kind == 'decision' else 156
|
|
92
|
+
p.rect(x, y, w, h, '#102F3B', 12)
|
|
93
|
+
# Small dotted construction grid gives the diagram a drawing-board texture.
|
|
94
|
+
for xx in range(16, int(w)-8, 22):
|
|
95
|
+
for yy in range(16, h-8, 22):
|
|
96
|
+
p.circle(x+xx, y+yy, .55, '#28434D')
|
|
97
|
+
p.rect(x+14, y+13, 3, 10, theme.bright, 1)
|
|
98
|
+
p.text(x+24, y+21, 'MAPA VISUAL', 7.4, theme.bright, 'PyNotasBold')
|
|
99
|
+
p.text(x+w-202, y+21, 'Esquema conceptual · sin resultados', 7, '#C3D4D8')
|
|
100
|
+
|
|
101
|
+
if kind == 'network':
|
|
102
|
+
layers = [[(x+72,y+q) for q in (56,83,110)],
|
|
103
|
+
[(x+208,y+q) for q in (46,70,94,118)],
|
|
104
|
+
[(x+337,y+q) for q in (69,99)]]
|
|
105
|
+
for a, b in zip(layers, layers[1:]):
|
|
106
|
+
for ax, ay in a:
|
|
107
|
+
for bx, by in b:
|
|
108
|
+
connector(p, ax+6, ay, bx-6, by, '#58647F')
|
|
109
|
+
for i, layer in enumerate(layers):
|
|
110
|
+
for xx, yy in layer:
|
|
111
|
+
p.circle(xx, yy, 12, '#3B465E')
|
|
112
|
+
p.circle(xx, yy, 7.2, (theme.bright, '#82DDC5', theme.secondary)[i])
|
|
113
|
+
p.circle(xx, yy, 2.3, '#FFFFFF')
|
|
114
|
+
for xx, label in [(x+40,'Entradas'),(x+172,'Capas conectadas'),(x+317,'Salidas')]:
|
|
115
|
+
p.text(xx, y+143, label, 8, '#EDF7F6')
|
|
116
|
+
p.line(x+385,y+45,x+385,y+124,'#45616A',.8)
|
|
117
|
+
_label(p,x+399,y+61,'Cada conexión participa en el cálculo.',w-414,'#E2EAF0',9)
|
|
118
|
+
_label(p,x+399,y+107,'La cantidad de nodos es ilustrativa.',w-414,'#B5C8D0',7.4)
|
|
119
|
+
|
|
120
|
+
elif kind == 'signal':
|
|
121
|
+
is_rms = 'rms' in keys
|
|
122
|
+
labels = ('Datos reales','Elevar al cuadrado','Promediar','Obtener la raíz') if is_rms else tuple(steps)
|
|
123
|
+
if not labels:
|
|
124
|
+
labels = ('Muestras','Operación','Representación')
|
|
125
|
+
count = min(len(labels), 4)
|
|
126
|
+
span = (w-30)/count
|
|
127
|
+
for i, label in enumerate(labels[:count]):
|
|
128
|
+
xx=x+15+i*span; cx=xx+span/2
|
|
129
|
+
p.text(xx+8,y+43,f'{i+1:02d}',7,theme.bright,'PyNotasBold')
|
|
130
|
+
if i==0 or (is_rms and i==1):
|
|
131
|
+
wave(p,xx+12,y+86,span-27,19,theme.bright,squared=(i==1))
|
|
132
|
+
elif is_rms and i==2:
|
|
133
|
+
for j,height in enumerate((23,11,33,18,26)):
|
|
134
|
+
p.rect(cx-31+j*13,y+105-height,8,height,'#557E80',2)
|
|
135
|
+
p.line(cx-37,y+81,cx+37,y+81,theme.secondary,2)
|
|
136
|
+
p.text(cx-19,y+62,'media',8.5,theme.secondary)
|
|
137
|
+
elif is_rms:
|
|
138
|
+
p.circle(cx,y+80,26,'#2B534F')
|
|
139
|
+
p.text(cx-17,y+91,'√',32,theme.bright,'PyNotasBold')
|
|
140
|
+
elif i==1 and 'fft' in keys:
|
|
141
|
+
p.text(cx-27,y+88,'FFT',22,theme.bright,'PyNotasBold')
|
|
142
|
+
else:
|
|
143
|
+
icon(p,cx,y+82,'bars' if 'fft' in keys else 'braces',theme.bright)
|
|
144
|
+
_label(p,xx+7,y+127,label,span-15,'#F4F7F7')
|
|
145
|
+
if i<count-1:
|
|
146
|
+
p.arrow(xx+span-10,y+83,xx+span+4,y+83,theme.secondary)
|
|
147
|
+
|
|
148
|
+
elif kind == 'learning':
|
|
149
|
+
split = 'split' in keys
|
|
150
|
+
icon(p,x+67,y+81,'table',theme.bright)
|
|
151
|
+
if split:
|
|
152
|
+
connector(p,x+104,y+81,x+221,y+57,theme.bright)
|
|
153
|
+
connector(p,x+104,y+81,x+221,y+107,theme.secondary)
|
|
154
|
+
p.rect(x+223,y+39,154,35,'#244A65',7)
|
|
155
|
+
p.rect(x+223,y+90,154,35,'#60463C',7)
|
|
156
|
+
p.text(x+237,y+61,'Entrenamiento',10,theme.bright,'PyNotasBold')
|
|
157
|
+
p.text(x+237,y+112,'Prueba',10,theme.secondary,'PyNotasBold')
|
|
158
|
+
_label(p,x+393,y+57,'Aprender con una parte.',w-407,'#D8E7F1',8.5)
|
|
159
|
+
_label(p,x+393,y+108,'Evaluar con otra.',w-407,'#F5DAC4',8.5)
|
|
160
|
+
p.text(x+38,y+139,'Conjunto de datos',8,'#E6EFF3')
|
|
161
|
+
else:
|
|
162
|
+
centers=(x+67,x+267,x+462)
|
|
163
|
+
p.arrow(centers[0]+41,y+82,centers[1]-39,y+82,theme.bright)
|
|
164
|
+
p.arrow(centers[1]+40,y+82,centers[2]-41,y+82,theme.secondary)
|
|
165
|
+
p.circle(centers[1],y+82,30,'#28465E')
|
|
166
|
+
icon(p,centers[1],y+82,'braces',theme.bright)
|
|
167
|
+
icon(p,centers[2],y+82,'dots',theme.secondary)
|
|
168
|
+
labels=tuple(steps) if len(steps)==3 else ('Datos','Operación reconocida','Salida conceptual')
|
|
169
|
+
for cx,label in zip(centers,labels):_label(p,cx-58,y+135,label,118,'#E6EFF3')
|
|
170
|
+
|
|
171
|
+
elif kind == 'decision':
|
|
172
|
+
cx=x+249;cy=y+72
|
|
173
|
+
p.path([('M',cx,cy-37),('L',cx+61,cy),('L',cx,cy+37),('L',cx-61,cy),('Z',)],
|
|
174
|
+
fill='#624936',stroke=theme.bright,width=1.4)
|
|
175
|
+
if decision:
|
|
176
|
+
from .layout import wrap
|
|
177
|
+
condition=decision.code.removeprefix('if ').removeprefix('elif ').rstrip(':').strip()
|
|
178
|
+
rows=wrap(condition,92,8,'PyNotasMono')
|
|
179
|
+
if len(rows)>2:rows=[rows[0],rows[1][:-1]+'…']
|
|
180
|
+
for i,row in enumerate(rows):p.text(cx-46,cy+3+(i-(len(rows)-1)/2)*12,row,8,theme.bright,'PyNotasMono')
|
|
181
|
+
p.text(cx-28,cy-43,f'CONDICIÓN · L{decision.line}',6.8,theme.bright,'PyNotasBold')
|
|
182
|
+
else:p.text(cx-11,cy+10,'?',30,theme.bright,'PyNotasBold')
|
|
183
|
+
p.text(x+22,y+68,'Evaluar',11,'#F6EEE6','PyNotasBold')
|
|
184
|
+
p.text(x+22,y+83,'la condición',9,'#C7D6D9')
|
|
185
|
+
p.arrow(x+121,cy,cx-67,cy,theme.bright)
|
|
186
|
+
connector(p,cx+63,cy,x+389,y+49,'#90DBBA')
|
|
187
|
+
connector(p,cx+63,cy,x+389,y+101,theme.secondary)
|
|
188
|
+
for yy,label,col in ((49,'Verdadero','#90DBBA'),(101,'Falso',theme.secondary)):
|
|
189
|
+
p.circle(x+399,y+yy,6,col);p.text(x+414,y+yy+4,label,10,col,'PyNotasBold')
|
|
190
|
+
p.text(x+157,y+131,'Se muestran alternativas; no una rama observada.',8,'#C7D6D9')
|
|
191
|
+
|
|
192
|
+
elif kind == 'resources':
|
|
193
|
+
names=list(dict.fromkeys(resources))[:5]
|
|
194
|
+
if not names:names=[('','Recursos')]
|
|
195
|
+
span=(w-28)/len(names)
|
|
196
|
+
from .layout import wrap,fonts
|
|
197
|
+
for i,(qualified,name) in enumerate(names):
|
|
198
|
+
cx=x+90 if len(names)==1 else x+14+(i+.5)*span
|
|
199
|
+
p.circle(cx,y+78,28,'#2C4661')
|
|
200
|
+
shape='bars' if 'matplotlib' in qualified or 'seaborn' in qualified else 'table' if qualified.split('.')[0] in {'pandas','numpy'} else 'braces'
|
|
201
|
+
icon(p,cx,y+77,shape,theme.bright)
|
|
202
|
+
if len(name)>24:name=name[:23]+'…'
|
|
203
|
+
if len(names)==1:
|
|
204
|
+
headline=qualified or name
|
|
205
|
+
if len(headline)>35:headline=headline[:34]+'…'
|
|
206
|
+
_label(p,x+160,y+66,headline,w-181,theme.bright,14)
|
|
207
|
+
p.text(x+160,y+96,'Nombre de uso: '+name,10,'#ECF1F8')
|
|
208
|
+
p.text(x+160,y+123,'Disponible para tus próximas instrucciones.',8,'#B9CED9')
|
|
209
|
+
else:
|
|
210
|
+
for j,row in enumerate(wrap(name,span-18,8.4)):
|
|
211
|
+
p.text(cx-fonts().stringWidth(row,'PyNotas',8.4)/2,y+125+j*11,row,8.4,'#ECF1F8')
|
|
212
|
+
# Imports are grouped tools, not a sequence or a data dependency graph.
|
|
213
|
+
|
|
214
|
+
else:
|
|
215
|
+
centers=(x+73,x+266,x+459)
|
|
216
|
+
icon(p,centers[0],y+82,'table',theme.bright)
|
|
217
|
+
p.circle(centers[1],y+82,32,'#255054')
|
|
218
|
+
icon(p,centers[1],y+82,'braces',theme.bright)
|
|
219
|
+
icon(p,centers[2],y+82,'dots',theme.secondary)
|
|
220
|
+
for a,b in zip(centers,centers[1:]):p.arrow(a+43,y+82,b-45,y+82,theme.bright)
|
|
221
|
+
for cx,label in zip(centers,('Datos de entrada','Instrucciones','Salida conceptual')):
|
|
222
|
+
p.text(cx-44,y+137,label,8.2,'#EEF5F5')
|
|
223
|
+
return h
|
pynotas/capture.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Copy bounded PNG/JPEG display messages, forwarding the original unchanged."""
|
|
2
|
+
import base64
|
|
3
|
+
import io
|
|
4
|
+
from .model import CapturedFigure
|
|
5
|
+
|
|
6
|
+
MAX_BYTES=8_000_000
|
|
7
|
+
MAX_PIXELS=20_000_000
|
|
8
|
+
MAX_FIGURES=4
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def raster(mime,payload):
|
|
12
|
+
from PIL import Image
|
|
13
|
+
if mime not in {'image/png','image/jpeg'}:raise ValueError('Formato no admitido')
|
|
14
|
+
if not isinstance(payload,(str,bytes)) or len(payload)>MAX_BYTES*4//3+8:raise ValueError('Imagen demasiado grande')
|
|
15
|
+
raw=base64.b64decode(payload,validate=True) if isinstance(payload,str) else payload
|
|
16
|
+
if len(raw)>MAX_BYTES:raise ValueError('Imagen demasiado grande')
|
|
17
|
+
with Image.open(io.BytesIO(raw)) as im:
|
|
18
|
+
w,h=im.size
|
|
19
|
+
if w*h>MAX_PIXELS or im.format!=({'image/png':'PNG','image/jpeg':'JPEG'}[mime]):raise ValueError('Imagen no admitida')
|
|
20
|
+
im.verify()
|
|
21
|
+
return CapturedFigure(mime,base64.b64encode(raw).decode('ascii'),w,h)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class DisplayCapture:
|
|
25
|
+
def __init__(self,publisher):
|
|
26
|
+
self.publisher=publisher;self.saved=[];self.images=[];self.notes=[];self.clear_pending=False
|
|
27
|
+
|
|
28
|
+
def note(self):
|
|
29
|
+
message='Alguna imagen no se incorporó por su formato, tamaño o límite de 4 imágenes. Consulta la salida original.'
|
|
30
|
+
if message not in self.notes:self.notes.append(message)
|
|
31
|
+
|
|
32
|
+
def receive(self,data,transient=None,update=False):
|
|
33
|
+
if not isinstance(data,dict):return
|
|
34
|
+
if self.clear_pending:self.images.clear();self.clear_pending=False
|
|
35
|
+
key=(transient or {}).get('display_id')
|
|
36
|
+
for mime in ('image/png','image/jpeg'):
|
|
37
|
+
if mime not in data:continue
|
|
38
|
+
try:
|
|
39
|
+
fig=raster(mime,data[mime])
|
|
40
|
+
index=next((i for i,(old,_) in enumerate(self.images) if key and old==key),None)
|
|
41
|
+
if update and index is None:return
|
|
42
|
+
candidates=list(self.images)
|
|
43
|
+
if index is not None:candidates[index]=(key,fig)
|
|
44
|
+
else:candidates.append((key,fig))
|
|
45
|
+
if len(candidates)>MAX_FIGURES or sum(len(f.data)*3//4 for _,f in candidates)>MAX_BYTES:raise ValueError('Límite')
|
|
46
|
+
self.images=candidates
|
|
47
|
+
except Exception:self.note()
|
|
48
|
+
return
|
|
49
|
+
|
|
50
|
+
def install(self):
|
|
51
|
+
original=self.publisher.publish
|
|
52
|
+
def publish(*args,**kwargs):
|
|
53
|
+
result=original(*args,**kwargs)
|
|
54
|
+
try:self.receive(kwargs.get('data',args[0] if args else None),kwargs.get('transient'),kwargs.get('update',False))
|
|
55
|
+
except Exception:self.note()
|
|
56
|
+
return result
|
|
57
|
+
self.wrap('publish',publish)
|
|
58
|
+
original_clear=getattr(self.publisher,'clear_output',None)
|
|
59
|
+
if original_clear:
|
|
60
|
+
def clear(*args,**kwargs):
|
|
61
|
+
result=original_clear(*args,**kwargs)
|
|
62
|
+
wait=kwargs.get('wait',args[0] if args else False)
|
|
63
|
+
if wait:self.clear_pending=True
|
|
64
|
+
else:self.images.clear();self.clear_pending=False
|
|
65
|
+
return result
|
|
66
|
+
self.wrap('clear_output',clear)
|
|
67
|
+
|
|
68
|
+
def wrap(self,name,wrapper):
|
|
69
|
+
namespace=vars(self.publisher)
|
|
70
|
+
self.saved.append((name,name in namespace,namespace.get(name),wrapper))
|
|
71
|
+
setattr(self.publisher,name,wrapper)
|
|
72
|
+
|
|
73
|
+
def restore(self):
|
|
74
|
+
for name,existed,old,wrapper in reversed(self.saved):
|
|
75
|
+
if getattr(self.publisher,name,None) is wrapper:
|
|
76
|
+
if existed:setattr(self.publisher,name,old)
|
|
77
|
+
else:delattr(self.publisher,name)
|
|
78
|
+
self.saved.clear()
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def figures(self):return tuple(f for _,f in self.images)
|
pynotas/cli.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Command-line entry point. User scripts are always read, never executed."""
|
|
2
|
+
import argparse
|
|
3
|
+
import sys
|
|
4
|
+
from .files import explicar_archivo
|
|
5
|
+
from ._version import __version__
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def main(argv=None):
|
|
9
|
+
parser = argparse.ArgumentParser(description='Explica un archivo Python como lámina HTML sin ejecutarlo.')
|
|
10
|
+
parser.add_argument('--version', action='version', version='PyNotas '+__version__)
|
|
11
|
+
parser.add_argument('archivo', help='Archivo .py que quieres comprender')
|
|
12
|
+
parser.add_argument('-o','--salida', help='Ruta del HTML; por defecto: archivo.pynotas.html')
|
|
13
|
+
parser.add_argument('--formato', choices=['auto','flujo','despiece','decisiones'], default='auto')
|
|
14
|
+
parser.add_argument('--sobrescribir', action='store_true', help='Permite reemplazar el HTML de destino')
|
|
15
|
+
args = parser.parse_args(argv)
|
|
16
|
+
try:
|
|
17
|
+
output = explicar_archivo(args.archivo,args.salida,sobrescribir=args.sobrescribir,formato=args.formato)
|
|
18
|
+
except (OSError,UnicodeError,SyntaxError,ValueError,RecursionError) as error:
|
|
19
|
+
print(f'No se pudo crear la lámina: {error}', file=sys.stderr)
|
|
20
|
+
return 2
|
|
21
|
+
print(f'Lámina guardada en {output}. El archivo Python no se ejecutó.')
|
|
22
|
+
return 0
|