mainframe-mocker 0.1.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.
Files changed (66) hide show
  1. mainframe_mocker-0.1.0.dist-info/METADATA +504 -0
  2. mainframe_mocker-0.1.0.dist-info/RECORD +66 -0
  3. mainframe_mocker-0.1.0.dist-info/WHEEL +4 -0
  4. mainframe_mocker-0.1.0.dist-info/entry_points.txt +6 -0
  5. mainframe_mocker-0.1.0.dist-info/licenses/LICENSE +21 -0
  6. mfmock/__init__.py +44 -0
  7. mfmock/__main__.py +76 -0
  8. mfmock/cli.py +78 -0
  9. mfmock/core/__init__.py +1 -0
  10. mfmock/core/capture.py +311 -0
  11. mfmock/core/cli.py +154 -0
  12. mfmock/core/ebcdic.py +24 -0
  13. mfmock/core/keys.py +83 -0
  14. mfmock/core/preview.py +11 -0
  15. mfmock/core/protocol.py +44 -0
  16. mfmock/core/server.py +164 -0
  17. mfmock/core/telnet.py +362 -0
  18. mfmock/core/terminal.py +33 -0
  19. mfmock/core/testing/__init__.py +11 -0
  20. mfmock/core/testing/emulator.py +46 -0
  21. mfmock/core/testing/replay.py +150 -0
  22. mfmock/core/testing/server.py +49 -0
  23. mfmock/core/testing/transport.py +178 -0
  24. mfmock/core/testing/virtual_terminal.py +219 -0
  25. mfmock/core/ui/__init__.py +20 -0
  26. mfmock/core/ui/app.py +158 -0
  27. mfmock/core/ui/conversation.py +188 -0
  28. mfmock/core/ui/frame.py +83 -0
  29. mfmock/core/ui/layout.py +202 -0
  30. mfmock/core/ui/messages.py +129 -0
  31. mfmock/core/ui/results.py +53 -0
  32. mfmock/core/ui/screen.py +144 -0
  33. mfmock/core/ui/style.py +105 -0
  34. mfmock/core/ui/template.py +452 -0
  35. mfmock/core/ui/validators.py +50 -0
  36. mfmock/core/ui/widgets.py +168 -0
  37. mfmock/demo/__init__.py +165 -0
  38. mfmock/demo/templates/fields.j2 +11 -0
  39. mfmock/demo/templates/hello.j2 +3 -0
  40. mfmock/demo/templates/signon.j2 +6 -0
  41. mfmock/py.typed +0 -0
  42. mfmock/pytest_plugin.py +153 -0
  43. mfmock/templates/goodbye.j2 +3 -0
  44. mfmock/templates/help.j2 +5 -0
  45. mfmock/templates/list.j2 +3 -0
  46. mfmock/templates/menu.j2 +3 -0
  47. mfmock/templates/widgets.j2 +30 -0
  48. mfmock/tn3270/__init__.py +7 -0
  49. mfmock/tn3270/adapter.py +60 -0
  50. mfmock/tn3270/cli.py +17 -0
  51. mfmock/tn3270/datastream.py +312 -0
  52. mfmock/tn3270/emulator.py +269 -0
  53. mfmock/tn3270/negotiation.py +145 -0
  54. mfmock/tn3270/renderer.py +123 -0
  55. mfmock/tn3270/s3270.py +121 -0
  56. mfmock/tn3270/terminal.py +31 -0
  57. mfmock/tn3270/testing.py +63 -0
  58. mfmock/tn5250/__init__.py +7 -0
  59. mfmock/tn5250/adapter.py +37 -0
  60. mfmock/tn5250/cli.py +28 -0
  61. mfmock/tn5250/datastream.py +258 -0
  62. mfmock/tn5250/emulator.py +301 -0
  63. mfmock/tn5250/negotiation.py +103 -0
  64. mfmock/tn5250/renderer.py +153 -0
  65. mfmock/tn5250/terminal.py +36 -0
  66. mfmock/tn5250/testing.py +50 -0
@@ -0,0 +1,504 @@
1
+ Metadata-Version: 2.4
2
+ Name: mainframe-mocker
3
+ Version: 0.1.0
4
+ Summary: Build mock IBM mainframe terminal applications (TN3270 and TN5250) and test terminal clients against them
5
+ Keywords: 3270,tn3270,5250,tn5250,mainframe,mock,terminal,emulator,testing,cics,ibm-i
6
+ Author: Ben Goldsmith
7
+ Author-email: Ben Goldsmith <ben.goldsmith@clearroute.io>
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Framework :: Pytest
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3 :: Only
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Programming Language :: Python :: 3.14
18
+ Classifier: Topic :: Software Development :: Testing
19
+ Classifier: Topic :: Software Development :: Testing :: Mocking
20
+ Classifier: Topic :: Terminals :: Telnet
21
+ Classifier: Typing :: Typed
22
+ Requires-Dist: jinja2>=3.1
23
+ Requires-Python: >=3.13
24
+ Project-URL: Homepage, https://github.com/bengieeee/mainframe-mocker
25
+ Project-URL: Documentation, https://github.com/bengieeee/mainframe-mocker#readme
26
+ Project-URL: Source, https://github.com/bengieeee/mainframe-mocker
27
+ Project-URL: Issues, https://github.com/bengieeee/mainframe-mocker/issues
28
+ Description-Content-Type: text/markdown
29
+
30
+ # mainframe-mocker
31
+
32
+ [![PyPI](https://img.shields.io/pypi/v/mainframe-mocker)](https://pypi.org/project/mainframe-mocker/)
33
+ [![Python](https://img.shields.io/pypi/pyversions/mainframe-mocker)](https://pypi.org/project/mainframe-mocker/)
34
+ [![CI](https://github.com/bengieeee/mainframe-mocker/actions/workflows/publish.yml/badge.svg)](https://github.com/bengieeee/mainframe-mocker/actions/workflows/publish.yml)
35
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue)](https://github.com/bengieeee/mainframe-mocker/blob/HEAD/LICENSE)
36
+
37
+ **Mock IBM mainframe green-screen hosts in plain Python.** You write the screens; `mfmock`
38
+ speaks the wire protocol. Real terminal emulators (x3270, c3270, s3270, tn5250, Reflection,
39
+ ZOC...), RPA bots and screen-scraping libraries connect to your mock as if it were a CICS,
40
+ TSO or IBM i host.
41
+
42
+ Use it to:
43
+
44
+ - **test terminal emulators and 3270/5250 client libraries** against a host you control, with
45
+ every screen size and colour model, TN3270E on or off, and TLS;
46
+ - **run integration and E2E tests** for RPA bots and screen-scraping code in CI, with no
47
+ mainframe, no VPN and no shared test region;
48
+ - **rebuild a real host flow from a recorded session** and check the mock against that
49
+ recording;
50
+ - **demo and train** on a mainframe flow that anybody can run on a laptop.
51
+
52
+ Features:
53
+
54
+ - **TN3270 / TN3270E** (models 2-5, mono and colour, TLS) and **TN5250** (IBM i, 24x80 and
55
+ 27x132, mono and colour, TLS). The same app serves both.
56
+ - **Screens are Jinja2 pictures:** a template looks like the screen, and Python keeps the
57
+ behaviour.
58
+ - **A test kit:**
59
+ - an in-memory virtual terminal that speaks real protocol bytes;
60
+ - a TCP client and an ephemeral-port server;
61
+ - a pytest plugin that runs each test over both protocols;
62
+ - a driver for the real `s3270` emulator;
63
+ - replay of recorded sessions.
64
+ - **Small footprint:** pure Python 3.13+, needing only the standard library and Jinja2.
65
+
66
+ ## Contents
67
+
68
+ - [Install](#install)
69
+ - [Try it in 30 seconds](#try-it-in-30-seconds)
70
+ - [A mock in five minutes](#a-mock-in-five-minutes)
71
+ - [Testing with mfmock](#testing-with-mfmock)
72
+ - [End-to-end testing of real clients](#end-to-end-testing-of-real-clients)
73
+ - [Concepts](#concepts)
74
+ - [Screen templates](#screen-templates)
75
+ - [Mocks from recordings](#mocks-from-recordings)
76
+ - [Command line and environment](#command-line-and-environment)
77
+ - [Protocols](#protocols)
78
+ - [Architecture](#architecture)
79
+ - [For AI agents and coding tools](#for-ai-agents-and-coding-tools)
80
+ - [Roadmap](#roadmap)
81
+ - [Development](#development)
82
+
83
+ More detail is in [`docs/`](https://github.com/bengieeee/mainframe-mocker/tree/HEAD/docs/): [templates](https://github.com/bengieeee/mainframe-mocker/blob/HEAD/docs/templates.md), [testing](https://github.com/bengieeee/mainframe-mocker/blob/HEAD/docs/testing.md),
84
+ [recordings](https://github.com/bengieeee/mainframe-mocker/blob/HEAD/docs/recordings.md), [protocols](https://github.com/bengieeee/mainframe-mocker/blob/HEAD/docs/protocols.md) and the [API](https://github.com/bengieeee/mainframe-mocker/blob/HEAD/docs/api.md).
85
+
86
+ ## Install
87
+
88
+ ```sh
89
+ pip install mainframe-mocker # or: uv add mainframe-mocker
90
+ ```
91
+
92
+ The distribution is `mainframe-mocker`, the import package is `mfmock`, and the command is
93
+ `mfmock`. Installing it also registers a pytest plugin (see [Testing](#testing-with-mfmock)).
94
+
95
+ ## Try it in 30 seconds
96
+
97
+ `mfmock demo` serves a small built-in host (sign-on, menu, a form, a paged list and a field
98
+ showcase):
99
+
100
+ ```sh
101
+ mfmock demo # TN3270 on port 3270
102
+ c3270 localhost:3270 # sign on as DEMO / DEMO
103
+
104
+ mfmock demo --protocol 5250 # TN5250 on port 5250
105
+ tn5250 localhost:5250
106
+
107
+ mfmock demo --port 2323 --users alice:secret,bob:hunter2 --trace --log-level DEBUG
108
+ ```
109
+
110
+ The demo has these screens and transaction codes:
111
+
112
+ | Screen | Transaction | Shows |
113
+ | -------------- | ---------------- | ------------------------------------------------------------------ |
114
+ | Sign-on | | an input field, a hidden password field, error messages |
115
+ | Main menu | `MENU` | a numbered menu |
116
+ | Greeting | `HELO` | a form and a message |
117
+ | Guest book | `LIST` | a paged list (PF7 and PF8) |
118
+ | Field showcase | `FLDS` | every colour, highlight, numeric field, hidden field and validator |
119
+ | Help, sign-off | `PF1`, `SIGNOFF` | |
120
+
121
+ Point an emulator, a bot or an E2E suite at it before you write a mock of your own.
122
+
123
+ ## A mock in five minutes
124
+
125
+ ```python
126
+ # hello.py
127
+ from mfmock import Application, Exit, MessageCatalog, Screen, Stay, msg, run
128
+
129
+ app = Application(
130
+ "HELLO",
131
+ start="MAIN",
132
+ catalog=MessageCatalog(prefix="HEL", messages={"GREETED": ("HEL0100I", "HELLO {name}")}),
133
+ pf_legend="ENTER=Greet PF3=Exit",
134
+ )
135
+
136
+
137
+ @app.screen
138
+ class Main(Screen):
139
+ name = tran = "MAIN"
140
+ title = "HELLO MAINFRAME"
141
+
142
+ template_source = """\
143
+ {{ row(5) }}{{ prompt(col=2) }}YOUR NAME . . : {{ input('name', 20, label='NAME', validators=[required]) }}
144
+ {{ row(7) }}{{ prompt(col=2) }}GREETING . . : {{ output('greeting', 50) }}
145
+ """ # or template = "main.j2", in a templates/ directory
146
+
147
+ def enter(self): # plain sync code; values are upper-cased text
148
+ self.values["greeting"] = f"HELLO, {self.v('name')}!"
149
+ return Stay(msg("GREETED", name=self.v("name")))
150
+
151
+ def pf(self, n):
152
+ return Exit() if n == 3 else None # None falls through to the app's PF defaults
153
+
154
+
155
+ if __name__ == "__main__":
156
+ run(app, protocols=("tn3270", "tn5250")) # --protocol, --port, --trace, --tls-*, env vars
157
+ ```
158
+
159
+ ```sh
160
+ python hello.py --port 3270 & # then: c3270 localhost:3270
161
+ ```
162
+
163
+ The frame adds the header (system ID, title, user, clock), the `TRAN:` command line, the
164
+ message line and the key legend. This is [`examples/hello.py`](https://github.com/bengieeee/mainframe-mocker/blob/HEAD/examples/hello.py); its tests
165
+ are in [`examples/test_hello.py`](https://github.com/bengieeee/mainframe-mocker/blob/HEAD/examples/test_hello.py).
166
+
167
+ ## Testing with mfmock
168
+
169
+ ### The pytest plugin
170
+
171
+ Installing `mainframe-mocker` registers a pytest plugin. Its fixtures run each test once per
172
+ protocol:
173
+
174
+ ```python
175
+ from hello import app
176
+
177
+
178
+ def test_greets(mf_terminal): # runs as test_greets[3270] and test_greets[5250]
179
+ t = mf_terminal(app) # an in-memory terminal: real protocol bytes, no sockets
180
+ t.enter(your_name="ada") # fill the field captioned "YOUR NAME", press Enter
181
+ assert t.message == "HEL0100I HELLO ADA"
182
+ assert "HELLO, ADA!" in t.text()
183
+ t.pf(3)
184
+ assert t.closed
185
+
186
+
187
+ def test_over_tcp(mf_server):
188
+ server = mf_server(app) # listening on 127.0.0.1:<free port> for this test
189
+ t = server.connect() # a TCP client with full telnet negotiation
190
+ assert "HELLO MAINFRAME" in t.row(1)
191
+ ```
192
+
193
+ | Fixture, option or marker | What it does |
194
+ | -------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- |
195
+ | `mf_terminal(app, terminal_type=None, **kw)` | opens an in-memory `VirtualTerminal`. The default terminal is 24x80 colour (`IBM-3279-2-E` or `IBM-3179-2`). `kw` takes `device_name=` and `peer=`. |
196
+ | `mf_server(app, config=None)` | serves the app on TCP. Returns an `MfServer` with `host`, `port`, `address` (`"host:port"`) and `connect(terminal_type=None)`. |
197
+ | `mf_protocol` | `"tn3270"` or `"tn5250"`, the protocol of this run |
198
+ | `--mf-protocol 3270` | chooses the protocols for the run: `3270`, `5250` or `3270,5250` (the default) |
199
+ | `@pytest.mark.mf_protocols("5250")` | limits one test to some protocols |
200
+
201
+ Terminals and servers are closed when the test ends.
202
+
203
+ ### The VirtualTerminal API
204
+
205
+ | Read the screen | Act |
206
+ | ------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------- |
207
+ | `text()`, `lines()`, `row(n)` (1-based) | `fill(target, value)` |
208
+ | `find(text)` gives `(row, col)` or `None` | `enter(**fill)`: keyword names become captions (`your_name` matches `YOUR NAME`) |
209
+ | `assert_text(*texts)` | `press(Key.PF5, **fill)`, `pf(n)`, `pa(n)`, `clear()` |
210
+ | `message`: the message line | `type(text)`, `move_cursor(row, col)` |
211
+ | `alarm`: whether the last screen sounded the alarm | `tran(code)`: type a transaction code and press Enter |
212
+ | `cursor`, `rows`, `cols`, `closed` | `close()` |
213
+ | `fields`, `all_fields`, `field(target)` give a `FieldView` (`value`, `hidden`, `numeric`, `protected`, `color`...) | |
214
+
215
+ A `target` is a caption on the screen (the first input field after it on the same row), a
216
+ 1-based `(row, col)` inside the field, or the field's index.
217
+
218
+ ### Without pytest
219
+
220
+ The same terminals come from the protocol packages:
221
+
222
+ ```python
223
+ from mfmock import ServerConfig
224
+ from mfmock.core.testing import running_server
225
+ from mfmock.tn3270 import TN3270Adapter
226
+ from mfmock.tn3270.testing import connect, virtual_terminal
227
+
228
+ from hello import app
229
+
230
+ with virtual_terminal(app, "IBM-3278-5") as t: # 27x132; any 3278/3279 model works
231
+ t.enter(your_name="grace")
232
+ assert "HELLO, GRACE!" in t.text()
233
+
234
+ config = ServerConfig(host="127.0.0.1", port=0) # port 0: any free port
235
+ with running_server(TN3270Adapter(), app, config) as server:
236
+ with connect("127.0.0.1", server.port, "IBM-3279-2-E") as t:
237
+ assert "HELLO MAINFRAME" in t.row(1)
238
+ ```
239
+
240
+ `mfmock.tn5250.testing` has the same `virtual_terminal` and `connect`. Its default terminal
241
+ is `IBM-3179-2`, and `connect` takes `device_name=`.
242
+
243
+ ## End-to-end testing of real clients
244
+
245
+ The mock is a real TN3270 or TN5250 server, so any client can be tested against it: an
246
+ emulator, an RPA bot, a `py3270` or `tnz` script, or your own client library.
247
+
248
+ **From pytest:** serve the app with `mf_server` and give the client `server.host` and
249
+ `server.port`. The built-in driver for the real `s3270` emulator shows the pattern:
250
+
251
+ ```python
252
+ import pytest
253
+
254
+ from mfmock.demo import create_app
255
+ from mfmock.tn3270.s3270 import S3270
256
+
257
+
258
+ @pytest.mark.mf_protocols("3270")
259
+ @pytest.mark.skipif(not S3270.available(), reason="s3270 not installed")
260
+ def test_real_emulator_signs_on(mf_server):
261
+ server = mf_server(create_app())
262
+ emulator = S3270() # or your client under test
263
+ try:
264
+ emulator.connect(server.host, server.port)
265
+ emulator.type("demo")
266
+ emulator.cmd("Tab()")
267
+ emulator.type("demo")
268
+ assert "MAIN MENU" in emulator.enter()
269
+ finally:
270
+ emulator.close()
271
+ ```
272
+
273
+ **From any language or CI system:** run the host as a process or a service:
274
+
275
+ ```sh
276
+ mfmock demo --host 127.0.0.1 --port 3270 & # or: python my_mock.py --port 3270 &
277
+ MFMOCK_PORT=3270 MFMOCK_PROTOCOL=5250 mfmock demo
278
+ ```
279
+
280
+ ```yaml
281
+ # GitHub Actions: a mock host for the job's E2E tests
282
+ - run: pip install mainframe-mocker
283
+ - run: mfmock demo --port 3270 &
284
+ - run: npm run test:e2e # your client connects to localhost:3270
285
+ ```
286
+
287
+ **What to test for:**
288
+
289
+ - Screen sizes and colour models. Pass a terminal type:
290
+ - 3270: `IBM-3278-2` to `-5` (mono), `IBM-3279-2` to `-5` (colour), `-E` for extended
291
+ attributes, and `IBM-DYNAMIC` (the size comes from the Query Reply);
292
+ - 5250: `IBM-3179-2`, `IBM-3477-FC` (27x132) and others.
293
+ - `--no-tn3270e` for clients that should fall back to plain TN3270.
294
+ - TLS: `--tls-port 992 --tls-cert cert.pem --tls-key key.pem`.
295
+ - Idle timeouts: `--idle-timeout 30`.
296
+ - Errors and the alarm: send an invalid entry and an unknown transaction.
297
+ - Disconnects: `Exit()` closes the session.
298
+ - `--trace` hex-dumps every record, to debug a client byte by byte.
299
+ - Each connection gets a device or LU name (`ServerConfig(device_prefix=...)` or
300
+ `device_names=`). Its logs are `key=value` lines tagged `lu=...`.
301
+
302
+ ## Concepts
303
+
304
+ | Concept | In short |
305
+ | ---------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
306
+ | `Application(name, start=, catalog=, frame=, theme=, templates=, idle_timeout=, signoff_trans=, default_access=, pf_legend=, ...)` | the host. `@app.screen` registers a screen and its transaction code. Subclass it to override hooks. |
307
+ | `Screen` | one screen. Class attributes: `name`, `tran`, `title`, `access`, `help_text`, `pf_legend`, `default_cursor`, `template` or `template_source`. |
308
+ | Handlers | `setup()`, `refresh()`, `resume()`, `enter()`, `pf(n)`, `scroll(delta)`, `clear()`, `pa(n)`. Each returns a Result, or `None` for the app's default behaviour. |
309
+ | Results | `Stay(msg, cursor=)`, `Goto(name, args)`, `Back()`, `Home()`, `Restart()` (sign off) and `Exit()` (disconnect) |
310
+ | `self.values`, `self.v(name)`, `self.state` | the screen's field values (typed text is upper-cased unless `upper=False`), one value (`""` if unset), the per-connection state |
311
+ | Errors | `raise ScreenError("REQUIRED", cursor="name", field="NAME")` from anywhere. Other exceptions show `...9999E UNEXPECTED ERROR - REFERENCE <ref>` and are logged with the reference. |
312
+ | `MessageCatalog(prefix, messages)` | message IDs `{prefix}nnnn{I,W,E}`, with library defaults (`REQUIRED`, `INVALID_FIELD`, `TRAN_UNKNOWN`, `NOT_AUTHORISED`, `TOP`, `BOTTOM`...) that you can override by ID. Use `msg("ID", **params)`. |
313
+ | Validators | `required`, `digits(n=None)`, `one_of(*options)`, `matches(regex)`, or any `(value, field) -> MessageRef \| None` |
314
+ | Widgets | `MenuScreen` (`options`), `ListScreen` (`fetch(offset, limit)`, paging, a selection column), `HelpScreen`, `GoodbyeScreen` |
315
+ | Frame and theme | `StandardFrame(sysid=...)` draws the chrome, and `PlainFrame` draws none. A `Theme` maps semantic styles (`label`, `input`, `msg_error`...) to a `Style(intensity, color, highlight)`, which degrades on mono terminals. |
316
+ | App hooks | `create_state(conv)`, `authorize(state, requirement)`, `on_restart(conv)`, `on_end(conv)`, `user_label(conv)`, `clock()` |
317
+ | Below the UI | any object with `async handle(session)` can be served. Use `session.send(bytes)`, `await session.receive(timeout)` and `session.terminal`. |
318
+
319
+ [`src/mfmock/demo`](https://github.com/bengieeee/mainframe-mocker/blob/HEAD/src/mfmock/demo/__init__.py) is a compact example of most of these.
320
+
321
+ ## Screen templates
322
+
323
+ A template is a Jinja2 picture of the screen body: each rendered line is a row, and tags mark
324
+ where protected text and fields begin.
325
+
326
+ ```jinja
327
+ {{ row(3) }}{{ center('*** SIGN ON ***') }}
328
+ {{ row(6) }}{{ prompt(col=17) }}USER ID . . . . :{{ input('userid', 8, col=36, validators=[required]) }}
329
+ {{ prompt(col=17) }}PASSWORD . . . . :{{ input('password', 8, col=36, hidden=True) }}
330
+ {% for i in range(rows - 20) %}
331
+ {{ row(10 + i) }}{{ output('line_' ~ i, cols - 2, col=2) }}
332
+ {% endfor %}
333
+ ```
334
+
335
+ | Tag | Meaning |
336
+ | ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ |
337
+ | `row(n)` | at the start of a line: this line is row `n` |
338
+ | `text(style='label', col=, length=)` | protected text up to the next tag |
339
+ | `prompt(...)` | a `text` that captions the next field, which is also the field's label in messages |
340
+ | `input(name, length, col=, ...)`, `output(...)` | a field. Options: `style`, `numeric`, `hidden`, `upper`, `align`, `initial`, `label`, `validators`, `wrap`, `end`. |
341
+ | `center(text, style='title')` | centred text |
342
+
343
+ - Templates see `rows`, `cols`, `screen`, the validators, `ljust` and `rjust`. One template
344
+ serves every screen size, and is compiled once per size.
345
+ - Mistakes raise `TemplateLayoutError` naming the template, row and column. Examples are an
346
+ overlap, a field off the screen, or text without a tag.
347
+ - Preview a template, with a column ruler and its fields:
348
+
349
+ ```sh
350
+ mfmock preview templates/signon.j2 --size 27x132 --set user='"ADA"'
351
+ ```
352
+
353
+ The library's own templates are `mfmock/menu.j2`, `list.j2`, `help.j2` and `goodbye.j2`, with
354
+ macros in `widgets.j2`. See [docs/templates.md](https://github.com/bengieeee/mainframe-mocker/blob/HEAD/docs/templates.md).
355
+
356
+ ## Mocks from recordings
357
+
358
+ To mock a real host, record a session with an emulator that saves an HTML session (capture)
359
+ report. Then rebuild the host screen by screen:
360
+
361
+ ```sh
362
+ mfmock capture flow.html # every screen, entry and key, numbered
363
+ mfmock capture flow.html --code 18 # a starting template for screen #18
364
+ ```
365
+
366
+ Check the mock against the recording. `replay` types the recorded data, presses the recorded
367
+ keys and compares every screen: text, cursor and the field map.
368
+
369
+ <!-- readme-test: compile-only (needs a recording) -->
370
+
371
+ ```python
372
+ from mfmock.core.capture import load_capture
373
+ from mfmock.core.testing import mask_row, replay
374
+ from mfmock.tn3270.testing import virtual_terminal
375
+
376
+
377
+ def test_matches_recording():
378
+ with virtual_terminal(app, device_name="TCPD00HK") as t:
379
+ replay(t, load_capture("tests/captures/flow.html"),
380
+ variables={"password": "SECRET"}, # values the recording hides
381
+ masks=[r"\d\d:\d\d:\d\d", mask_row(11, 51)]) # clocks, client IP
382
+ ```
383
+
384
+ The first screen that differs raises `ReplayMismatch`, which lists the differences. See
385
+ [docs/recordings.md](https://github.com/bengieeee/mainframe-mocker/blob/HEAD/docs/recordings.md).
386
+
387
+ ## Command line and environment
388
+
389
+ `mfmock.run(app, argv=None, protocols=None, prog=, description=, env_prefix="MFMOCK_", config=ServerConfig(...), add_arguments=)`
390
+ gives every mock these flags. `mfmock demo` has them too.
391
+
392
+ | Flag | Environment variable | Default |
393
+ | ------------------------------------------------------------- | ------------------------------------------- | ----------------------------------------------------------------------- |
394
+ | `--protocol {3270,5250}` (when there are several `protocols`) | `<PREFIX>PROTOCOL` | the first protocol |
395
+ | `--host`, `--port` | `<PREFIX>HOST`, `<PREFIX>PORT` | `0.0.0.0`; `3270` (`5250` for TN5250 when it is not the first protocol) |
396
+ | `--log-level` | `<PREFIX>LOG_LEVEL` | `INFO` |
397
+ | `--idle-timeout SECONDS` | `<PREFIX>IDLE_TIMEOUT` | the app's |
398
+ | `--trace` (hex-dump records) | `<PREFIX>TRACE` | off |
399
+ | `--tls-port`, `--tls-cert`, `--tls-key` | `<PREFIX>TLS_PORT`, `_TLS_CERT`, `_TLS_KEY` | off |
400
+ | `--no-tn3270e` | `<PREFIX>TN3270E=false` | TN3270E offered |
401
+ | `--codepage` (TN5250) | `<PREFIX>CODEPAGE` | `cp037` |
402
+
403
+ Other `mfmock` commands:
404
+
405
+ | Command | What it does |
406
+ | ------------------------------------------------------ | ------------------------------------------------------------------- |
407
+ | `mfmock demo [--users U:P,...]` | serves the demo host |
408
+ | `mfmock preview FILE.j2 [--size RxC] [--set key=json]` | prints a template's picture and fields |
409
+ | `mfmock capture FILE.html [--code SEQ]` | summarises a recording, or writes a template for one of its screens |
410
+ | `mfmock --version` | prints the version |
411
+
412
+ ## Protocols
413
+
414
+ | | TN3270 / TN3270E | TN5250 |
415
+ | ------------- | ------------------------------------------------------------------------ | ---------------------------------------------------------------------- |
416
+ | Package | `mfmock.tn3270` | `mfmock.tn5250` |
417
+ | Negotiation | TN3270E (device type, LU name, functions) or plain TN3270; Query Reply | NEW-ENVIRON (optional), TERMINAL-TYPE, BINARY, EOR |
418
+ | Terminals | `IBM-3278-2` to `-5`, `IBM-3279-2` to `-5`, `-E` variants, `IBM-DYNAMIC` | `IBM-3179-2`, `IBM-5555-C01`, `IBM-5251-11`, `IBM-3477-FC` (27x132)... |
419
+ | Keys | Enter, PF1-24, PA1-3, Clear | Enter, F1-24, Roll Up/Down, Clear, Help, Print, Attn, SysReq |
420
+ | Not supported | printer sessions, DBCS | the 5250 query, Write Error Code, auto sign-on, DBCS, printers |
421
+
422
+ An app runs unchanged on both protocols. On 5250, an input field cannot start at row 1,
423
+ column 1. See [docs/protocols.md](https://github.com/bengieeee/mainframe-mocker/blob/HEAD/docs/protocols.md).
424
+
425
+ ## Architecture
426
+
427
+ ```
428
+ your app Application · @app.screen class X(Screen) · templates · catalog
429
+ mfmock.core.ui Conversation (navigation, keys, TRAN routing, timeouts, errors) · Layout · Frame · Theme
430
+ ─────────────── ScreenRenderer (seam) ───────────────
431
+ mfmock.tn3270 Layout → 3270 orders mfmock.tn5250 Layout → 5250 WTD orders
432
+ ─────────────── ProtocolAdapter (seam) ──────────────
433
+ mfmock.tn3270 TN3270(E) negotiation mfmock.tn5250 NEW-ENVIRON / TTYPE negotiation
434
+ mfmock.core MainframeServer · DeviceSession (TCP, TLS, LU names, logs, trace) · telnet · EBCDIC
435
+ ```
436
+
437
+ These rules are enforced by `tests/test_architecture.py`:
438
+
439
+ - `mfmock.core` never imports a protocol package.
440
+ - The library imports only the standard library and `jinja2`.
441
+ - There is no global state, so several apps and protocols can run in one process.
442
+
443
+ ## For AI agents and coding tools
444
+
445
+ - [`AGENTS.md`](https://github.com/bengieeee/mainframe-mocker/blob/HEAD/AGENTS.md) is for agents working on this repository: the layout, commands,
446
+ rules and pitfalls. [`.github/copilot-instructions.md`](https://github.com/bengieeee/mainframe-mocker/blob/HEAD/.github/copilot-instructions.md)
447
+ points to it.
448
+ - [`llms.txt`](https://github.com/bengieeee/mainframe-mocker/blob/HEAD/llms.txt) is a short, link-indexed summary for LLMs writing code that uses
449
+ `mfmock`.
450
+ - To help agents in your own project, copy the
451
+ ["Using mfmock from another project"](https://github.com/bengieeee/mainframe-mocker/blob/HEAD/AGENTS.md#using-mfmock-from-another-project) snippet
452
+ into its agent instructions.
453
+
454
+ ## Roadmap
455
+
456
+ These ideas would make `mfmock` more useful for integration and E2E testing. Contributions
457
+ are welcome.
458
+
459
+ 1. **`mfmock serve-capture flow.html`:** serve a recording as a scripted mock, with no Python
460
+ (each screen follows the recorded keys).
461
+ 2. **Declarative mocks:** screens and transitions in YAML or JSON, so teams that don't write
462
+ Python can define hosts.
463
+ 3. **Fault injection:**
464
+ - latency and keyboard-lock delays;
465
+ - forced disconnects;
466
+ - refused or partial TN3270E negotiation, rejected terminal types;
467
+ - malformed or oversized records.
468
+
469
+ These test how a client handles errors.
470
+
471
+ 4. **Session recording:**
472
+ - write every record and screen to JSON Lines;
473
+ - `replay` that format;
474
+ - a pass-through proxy that records a real host.
475
+ 5. **Assertions that wait:**
476
+ - `wait_for_text(text, timeout)` and `wait_until(predicate)` for hosts that update the
477
+ screen on their own;
478
+ - `snapshot()` with golden files;
479
+ - richer `ReplayMismatch` diffs.
480
+ 6. **Async API:** an `AsyncVirtualTerminal` and an async `running_server` for asyncio test
481
+ suites.
482
+ 7. **Reach:**
483
+ - support Python 3.11+;
484
+ - a Docker image running `mfmock demo`;
485
+ - a list of clients tested against `mfmock`.
486
+
487
+ ## Development
488
+
489
+ ```sh
490
+ uv sync # Python 3.13+, installs pytest and ruff
491
+ uv run pytest -q # unit tests, examples and the README's samples
492
+ uv run pytest -q -m integration # with a real emulator (needs s3270: brew install x3270)
493
+ uv run ruff check .
494
+ npx prettier --check . # Markdown and YAML formatting (--write to fix)
495
+ uv build
496
+ ```
497
+
498
+ Every push runs the tests, Ruff and Prettier (`npx prettier --check .`). Pushes to `main`
499
+ publish a release to PyPI and pushes to `develop` publish a `.devN` pre-release; the version is
500
+ computed at build time. See [docs/publishing.md](https://github.com/bengieeee/mainframe-mocker/blob/HEAD/docs/publishing.md).
501
+
502
+ ## License
503
+
504
+ [MIT](https://github.com/bengieeee/mainframe-mocker/blob/HEAD/LICENSE)
@@ -0,0 +1,66 @@
1
+ mfmock/__init__.py,sha256=Hf-Yk6zoO9Gn3UiM51CnmE7loIFnLHvHMS-5muxrcVE,1644
2
+ mfmock/__main__.py,sha256=sEZ43_q2gmGfHxgOv8TpNSrAkzvEyznw69nNach9LpM,2900
3
+ mfmock/cli.py,sha256=2JQfe66aBwtcWqV40jm5HC7iokDh6iwDvk1o9i362D8,3058
4
+ mfmock/core/__init__.py,sha256=Pp1gf0ISdIc-jPHh8Tufha5QHjUCHPDSv4hH85R_oIo,82
5
+ mfmock/core/capture.py,sha256=1GtQF0E80a3Syk8ZeJ9PVflW1k8hSpj0uu4ZUv2o-2s,12356
6
+ mfmock/core/cli.py,sha256=mi6srbbrz64wC3Y8LdEyhe8lwPMMrU-L7lzztDoaZFs,6651
7
+ mfmock/core/ebcdic.py,sha256=CcUWMuMj6gTBvObiiaTnB96mvt8MJs5iuWYpzlo-Q6I,604
8
+ mfmock/core/keys.py,sha256=1b9bpcaF_0N-eOW8XXguUCpDFMdFGRWaqTKurRDhe1M,2134
9
+ mfmock/core/preview.py,sha256=o-YD4AMxMoXXZN0n28U8eTt_A2sdg7XXFSUjGBcBOmo,323
10
+ mfmock/core/protocol.py,sha256=xucB2T58i18QmwvFWxzW9dLugYpuFAGc6SD1AXyBu28,1573
11
+ mfmock/core/server.py,sha256=pNAQa0yUqIjhIF01VdrSWfNfXTK2LkB9-3b-NCHdh9s,6559
12
+ mfmock/core/telnet.py,sha256=5FF6WbspYXtIvvPEuGRF613u7yM8rZmz9nFxv-E74vk,13153
13
+ mfmock/core/terminal.py,sha256=SGyMZ_f32BKoE0haiMkfFRpDpHzpUkxQc6YTRPkDjFg,1036
14
+ mfmock/core/testing/__init__.py,sha256=-Y63cD82dQ4PnmVtaVCpoSE-pC_TIHq-VhTnJKiGqXc,662
15
+ mfmock/core/testing/emulator.py,sha256=6HzDg5HNKMuAybYFxa3xjOI29tY9urUi6bjkw6peMEA,1274
16
+ mfmock/core/testing/replay.py,sha256=u-AdO0_4WMFg6Jc03DVFwR2glCYbYGF5Y-j1bkN_rJ4,7193
17
+ mfmock/core/testing/server.py,sha256=f6Y2qKqnUlEHbAjLlQ7V83EfTSFExgCwqBgs1EhZFdU,1517
18
+ mfmock/core/testing/transport.py,sha256=j1xNiIbwn3TdjKMHtV1orTc3GJzigOmN03CsFJi1dH0,6307
19
+ mfmock/core/testing/virtual_terminal.py,sha256=8jyi4_dG5L-HZd8Z3kT1-67-mlF4S0MACdyalw0OxhE,8058
20
+ mfmock/core/ui/__init__.py,sha256=ynXw7c6cWGK3HPmHIAHOYmmVflLS_3PDYEDZ6GpKEOA,1277
21
+ mfmock/core/ui/app.py,sha256=wRGERZrk78I39h9uXdRZSUjNxIg_NXCI8u6vUFt_fn0,6059
22
+ mfmock/core/ui/conversation.py,sha256=qBT08bZemEqBAVSV6fRgREyVaW1NUZmeBw8BJFwGTxw,7637
23
+ mfmock/core/ui/frame.py,sha256=FK20B5s9VHlnkERch2yDfpCP-EzHsarQ92cRXTohELw,3311
24
+ mfmock/core/ui/layout.py,sha256=639G-j-Zc7Qa9jnq6e56Io8lNY6ihlu8o2aYPRszFeE,8575
25
+ mfmock/core/ui/messages.py,sha256=P0gnjWHfGZsylZilVhYzNFQ2LR1Jw9Aj1H2nNybjCGs,5017
26
+ mfmock/core/ui/results.py,sha256=25zUyNrfQK-fJrMBSCCa8x6SLCMbeLMt9fg4CpC00YA,1054
27
+ mfmock/core/ui/screen.py,sha256=QD4vUt6dCmybDqlhEOE7rSVWkcU6MbtF1-PoSHhWQko,5451
28
+ mfmock/core/ui/style.py,sha256=xWQifnq6PT_y0u2hkngbxuQ25q2zgVXzJ8vW2BgQPaU,3730
29
+ mfmock/core/ui/template.py,sha256=RpNqvjqFS3aORRk0ZD3mmrwUVrUaqIVvoieUV2UlNhw,20324
30
+ mfmock/core/ui/validators.py,sha256=f8BO4562i-EChTeYeSJyxMPr5OVS9_NK7Vp5mgZ3-8M,1504
31
+ mfmock/core/ui/widgets.py,sha256=N8E4YfogWI3iRasZdkIzQJNW_wbbeFTq8bqyD5BIZ2I,5989
32
+ mfmock/demo/__init__.py,sha256=_0lUe_mlqyYZzF1of_NN_1F-NtH4GvrMhZaQwCt0i3Y,5297
33
+ mfmock/demo/templates/fields.j2,sha256=8C69O9ZcvojOV75wccILHFFare7F0jCVvv58O2Z78RI,1043
34
+ mfmock/demo/templates/hello.j2,sha256=rLqpzfCQRfNAOj3PFCSNrUBHqnqXhs5Px4ch3QCNd0I,258
35
+ mfmock/demo/templates/signon.j2,sha256=bixR5hVaYjhDAqArpL8KVpMt_fcGGt0lNSlS_WH-2IA,580
36
+ mfmock/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
37
+ mfmock/pytest_plugin.py,sha256=MOhX1nFKoRk8rWSWTROkrKzQkqz2fxNOV23OKL1bdeA,5738
38
+ mfmock/templates/goodbye.j2,sha256=ejhzv5IZoajtJ55uYjyk2QgPp2y1ymzR2rniJkYOXbQ,239
39
+ mfmock/templates/help.j2,sha256=H3PGoLqMuVC6gEZlUJ0BNxkLamUUUgGJU9Th7CLPlEc,259
40
+ mfmock/templates/list.j2,sha256=d1QraF5w8FBmNLTOMR2H0NycMbGAIHmWIK3CNO6z2Vc,137
41
+ mfmock/templates/menu.j2,sha256=9kPfJRve3CVkK3k79u_f4ST4UgjZou8msw6KZPQsVoc,151
42
+ mfmock/templates/widgets.j2,sha256=mP1ueEADF7-2mcmEihIhKmVFwebPZ9Sz6XfOrvi1N50,1399
43
+ mfmock/tn3270/__init__.py,sha256=a_o589pH9wSlFPOnYM-hIbrOEkrTxnoUxlU3dZQGQ8s,324
44
+ mfmock/tn3270/adapter.py,sha256=VLsYNdmi5_7Zs-ei88vMur75bHk6EJtSujnaUzVn1lo,2631
45
+ mfmock/tn3270/cli.py,sha256=g6gZZ57LueVQQ8JOioubcH-CQzJe08Oj8SSv7w0kPnY,550
46
+ mfmock/tn3270/datastream.py,sha256=FdUR7MgUdMnRudgBdyIMnMDkr9JgGaPvDziMtKqJNGM,9313
47
+ mfmock/tn3270/emulator.py,sha256=6xreOcalMEOwaJWjvz74SMshCXTSJF-bxFcSKRnoSW4,11317
48
+ mfmock/tn3270/negotiation.py,sha256=TsAMvHTW-BL6g5YIOY3LTe7viXeCadeYWuH1SglTx4k,5647
49
+ mfmock/tn3270/renderer.py,sha256=sSzKPPHqX7HR5AevueqxNarduLowCY4lYyCuSXMQa90,5551
50
+ mfmock/tn3270/s3270.py,sha256=28_VWlFYg4Mlj2p9AGoYWkkJATQmmeIDYp9MmnYQ9J4,3839
51
+ mfmock/tn3270/terminal.py,sha256=vIyHI2j2PFgmwzcfhpfxcoYL8v9K1Yr1yJOBVdzOkas,1287
52
+ mfmock/tn3270/testing.py,sha256=GUc8RsYHKNqVf7AOpQzKQj-um0PFk6p8x30_pTXKIwM,2930
53
+ mfmock/tn5250/__init__.py,sha256=4cLXnXW5a6iFkKOxEM1nzmvYLudIclccxssdODJHPME,329
54
+ mfmock/tn5250/adapter.py,sha256=Dt1HyVHqp3l1n4sHiq_LswJc4231WH691bGK47McCjA,1495
55
+ mfmock/tn5250/cli.py,sha256=hWmeS-BOtIPJazdOEEpN32UVXAJJ7yR8I0eyQvuXpnE,854
56
+ mfmock/tn5250/datastream.py,sha256=b-KGZbVfKr11wJwGHBGhWOavxlqrswDdaT5DFXyX0nM,9258
57
+ mfmock/tn5250/emulator.py,sha256=BNtybmAEM6MraIVQkszUCjI4LjyFeJ4-b29lwjVNGok,12907
58
+ mfmock/tn5250/negotiation.py,sha256=4OvW27k-C60IubQZUvsJgvP92aZLulBZpwJf02wAmXM,3924
59
+ mfmock/tn5250/renderer.py,sha256=_u_89wBjqJCBYABr2VzK8DaBrai-uKilTYZTAEtD3yA,7217
60
+ mfmock/tn5250/terminal.py,sha256=hSJojYX4cgDaEq9xmH_N7IBwE4ZgfnV0eviZDTrIHDs,1260
61
+ mfmock/tn5250/testing.py,sha256=YigxvdW0MzuFxgvDpP_GMyyPO7MrwYmrVnkIb66yRqk,2337
62
+ mainframe_mocker-0.1.0.dist-info/licenses/LICENSE,sha256=YugU-STp9FsT407GRyq3cMcO9RjsizqXPlKnq1j6f04,1070
63
+ mainframe_mocker-0.1.0.dist-info/WHEEL,sha256=1dD_GoEqEIxQ7VnrUExgdj_eExY4b4UgfR0iBlGKVac,80
64
+ mainframe_mocker-0.1.0.dist-info/entry_points.txt,sha256=TSq_ITdEoWbYnzmqVStY8SQXh2Wa8GjEEaaWBZLoYYw,91
65
+ mainframe_mocker-0.1.0.dist-info/METADATA,sha256=3lqva-CDZ0tXhFkBWF6TqwrholbEXGjnLY4aksH0Hk4,31716
66
+ mainframe_mocker-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.13.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,6 @@
1
+ [console_scripts]
2
+ mfmock = mfmock.__main__:main
3
+
4
+ [pytest11]
5
+ mfmock = mfmock.pytest_plugin
6
+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ben Goldsmith
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
mfmock/__init__.py ADDED
@@ -0,0 +1,44 @@
1
+ """mainframe-mocker: build mock IBM mainframe terminal applications.
2
+
3
+ Packages:
4
+ mfmock.core protocol-neutral server, screen toolkit, conversation engine, testing
5
+ mfmock.tn3270 the IBM 3270 (TN3270 / TN3270E) protocol backend
6
+ mfmock.tn5250 the IBM 5250 (TN5250) protocol backend
7
+
8
+ The screen toolkit is re-exported here, so a whole mock can be written against ``mfmock``::
9
+
10
+ from mfmock import Application, Screen, Stay, Exit, required, run
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from collections.abc import Sequence
16
+ from importlib.metadata import PackageNotFoundError, version
17
+ from typing import Any
18
+
19
+ from .core.keys import InputEvent, Key
20
+ from .core.server import DeviceSession, MainframeServer, ServerConfig
21
+ from .core.terminal import Terminal
22
+ from .core.ui import * # noqa: F403
23
+ from .core.ui import __all__ as _ui_all
24
+
25
+ try:
26
+ __version__ = version("mainframe-mocker")
27
+ except PackageNotFoundError: # running from a source tree without installing
28
+ __version__ = "0.0.0"
29
+
30
+
31
+ def run(app: Any, argv: Sequence[str] | None = None, *, protocols: Sequence[str] | None = None,
32
+ **kwargs: Any) -> None:
33
+ """Serve ``app`` with the standard flags (see ``mfmock.core.cli.run``).
34
+
35
+ By default over TN3270. With ``protocols=("tn3270", "tn5250")`` a ``--protocol {3270,5250}``
36
+ flag (or ``<env_prefix>PROTOCOL``) chooses; the first protocol is the default.
37
+ """
38
+ from .cli import adapter_options
39
+ from .core.cli import run as _run
40
+
41
+ _run(app, adapter_options(protocols), argv, **kwargs)
42
+
43
+
44
+ __all__ = [*_ui_all, "DeviceSession", "InputEvent", "Key", "MainframeServer", "ServerConfig", "Terminal", "run"]