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 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
@@ -0,0 +1,2 @@
1
+ from .cli import main
2
+ raise SystemExit(main())
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