Skip to content

Consolidate the four vision exercises into two modular labs - #83

Draft
ivorb wants to merge 13 commits into
mainfrom
ivorb-consolidated-labs
Draft

Consolidate the four vision exercises into two modular labs#83
ivorb wants to merge 13 commits into
mainfrom
ivorb-consolidated-labs

Conversation

@ivorb

@ivorb ivorb commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator

Consolidates the four vision exercises into two larger, modular labs following the Core + Optional template piloted in mslearn-ai-agents (Lab A/B/C). Reskinned to a single Wide World Importers scenario.

Important

Scope note: this PR is additive except for one deliberate, user-approved edit to a shared root file — a one-line change to index.md (see "Shared-file change" below). Exercises 01–04 and their Labfiles/ trees are untouched.

Grouping

The four sources split cleanly along understanding vs. generating, so that became the seam rather than one 4-task lab.

Lab Core Optional From
A Analyze visual content with AI A1 Ask a model about an image A2 Local image file · A3 Content Understanding 01, 04
B Generate images and video with AI B1 Generate images B2 Video from text · B3 Reference image + remix 02, 03

Two source labs became two tasks each, preserving every original step. Lab 01's URL-image and local-file halves became standalone A1/A2 (separate files, so A1's code survives for comparison). Lab 03's six code blanks split across B2 (create/poll/download) and B3 (reference image/remix), with B3 shipping the polling helpers pre-written so it stands alone.

Each lab ships a starter Python/ tree with fill-in-the-blank scaffolds, a complete Solution/Python/ tree, a Solution/README.md, and setup/check_env.py --task N for per-task preflight.

Shared-file change: index.md

index.md's Liquid loop lists every page under /Instructions/Exercises that has a lab.title, so the new consolidated pages were auto-listed alongside the originals — including two identically-titled "Getting started" entries. Per user decision, the loop now skips drafts:

-{% if activity.lab.title %}
+{% if activity.lab.title and activity.lab.status != 'draft' %}

A missing status is treated as publishable, not draft, so legacy pages without the field are unaffected. Verified by rendering the real condition in a Liquid engine against the repo's actual frontmatter plus synthetic no-status / released / draft pages: the 4 released exercises and both synthetic non-draft pages still list; all 10 consolidated drafts drop out.

Currency pass (old → new)

Change Verified against
Auth scope cognitiveservices.azure.com/.defaultai.azure.com/.default v1 API lifecycle, Responses API
AnalysisInput(data=bytes)begin_analyze_binary(binary_input=bytes) azure-sdk-for-python _patch.py + sample_analyze_binary.pyAnalysisInput has no data=
.value_string / .value_arraytyped .value SDK 1.0.0 CHANGELOG; sample_analyze_invoice.py
dotenvpython-dotenv; pinned azure-ai-contentunderstanding==1.1.0 PyPI — 1.1.0 is current GA, maps to API 2025-11-01
Prose Sora-2sora-2; seconds='4'seconds=4 Sora 2 overview
Quota fallback gpt-5.2-minigpt-4.1 / gpt-4o models sold directly by Azure
Bug fix: api_key=token_providerapi_key=token_provider() Source lab 03 passed the callable, not a string — fails at runtime

Confirmed current and left alone: gpt-5.2 (GA, image-capable), gpt-image-2 (GA), base URL …openai.azure.com/openai/v1/, API version 2025-11-01, Studio URL, "Microsoft Foundry" branding.

Reliability fixes to setup/check_env.py

Three defects inherited from the reference repo's template, all caught during cross-repo review and fixed in both labs:

  1. Wrong relative path. Pages said run python setup/check_env.py "from the Labfiles/<lab> folder", but the page flow puts learners in Python/ (where VS Code opens, and where the venv and .env live). It failed with [Errno 2] No such file. Now ../setup/check_env.py, with the surrounding prose corrected to name the Python folder. Repo-wide grep for un-prefixed occurrences: 0.
  2. Hard dependency on python-dotenv. The preflight is meant to run before pip install, but died with ModuleNotFoundError on a clean interpreter — even --help failed. Now falls back to a stdlib .env parser, verified byte-identical to dotenv_values across quoted, single-quoted, export-prefixed, inline-comment, comment-line, empty and bare-key cases.
  3. UTF-8 BOM broke the first key. A .env saved by some Windows editors carries a BOM, which python-dotenv glues onto the first key (\ufeffOPENAI_ENDPOINT) — making a correctly-set key report as MISSING. Demonstrated, then fixed by stripping the BOM from keys so both code paths agree.

Validation

Check Result
py_compile, 14 files
ASCII scan of all .py ✅ pure ASCII
SDK symbol resolution in a real venv ✅ 24/24 — every videos.* / responses / images call + param, CU client/methods
CU field accessors executed StringField.value'hello'; ArrayField.value iteration → ['tag-a','tag-b']
check_env on both interpreters (venv + clean, no dotenv), from cwd=Python/ --help exit 0; --task 1/2/3 identical on both
BOM'd .env with keys set ✅ passes on both paths; no regression on a normal .env
Instruction snippets vs Solution files ✅ all 22 blocks match exactly
Instruction anchors vs starter files ✅ all 22 present
Media/image refs resolve at the new folder depth ✅ 7/7 (../../media/, ../../../Labfiles/…); zero shallow ../media/
Persona CSS asset URLs ✅ none — inherited avatar url() was dropped during authoring
Relative page links ✅ all resolve
Difficulty meters ✅ headers + 3 glance rows per landing; none on task pages
Brand consistency ✅ zero stray fictional companies
index.md loop rendered in a real Liquid engine ✅ released + no-status pages list; all 10 drafts hidden
Nothing junk staged ✅ no __pycache__, no real .env, no venv
code-review pass ✅ found 1 real starter/solution divergence — fixed and re-validated

Reviewer notes / flags

  1. Not runtime-tested against Azure. Lab B tasks 2–3 need sora-2, which is preview and access-restricted. Those paths are validated by compile + symbol resolution + fidelity to current docs, as with portal-only tasks. Worth a human smoke test before merge.
  2. The Content Understanding change is the riskiest. begin_analyze_binary and .value genuinely change the exercise's API surface — but the source's AnalysisInput(data=) does not exist in the GA SDK, so the original code cannot run. Fixed rather than flagged-and-left. Please verify against a live analyzer.
  3. Published asset URLs (…/Labfiles/A-analyze-visual-content-with-ai/orange.jpeg) only resolve after merge to main. Assets were copied into the new tree rather than pointing at the old labs' paths, so the labs stay self-contained if 01–04 are ever retired.
  4. All new pages are status: 'draft'. Combined with the index.md filter, they're invisible on the site until someone promotes them — so this can merge without changing what learners see.

🤖 Generated with Copilot CLI

ivorb and others added 13 commits August 13, 2026 16:01
Adds a pilot of the Core + Optional lab template to this repo, reskinned to a
single Wide World Importers scenario. Purely additive: the existing exercises
01-04 and their Labfiles trees are untouched.

Lab A - Analyze visual content with AI (from 01-gen-ai-vision, 04-content-understanding)
  A1 Core     Ask a model about an image (Responses API, image from a URL)
  A2 Optional Send a local image file (base64 data URL)
  A3 Optional Extract structured metadata with Content Understanding

Lab B - Generate images and video with AI (from 02-generate-image, 03-generate-video)
  B1 Core     Generate images from a prompt (gpt-image-2)
  B2 Optional Generate video from a text prompt (sora-2, create/poll/download)
  B3 Optional Animate a reference image and remix it

Instruction pages live in Instructions/Exercises/Consolidated/. Each lab ships a
starter Python tree with fill-in-the-blank scaffolds, a complete Solution tree,
a Solution README, and setup/check_env.py --task N for per-task preflight.

Currency pass (verified against Microsoft Learn / Azure SDK sources):
  - Entra ID scope cognitiveservices.azure.com/.default -> ai.azure.com/.default
  - Content Understanding AnalysisInput(data=) -> begin_analyze_binary(binary_input=)
  - Content Understanding .value_string/.value_array -> typed .value
  - requirements dotenv -> python-dotenv; pinned azure-ai-contentunderstanding==1.1.0
  - Sora prose Sora-2 -> sora-2; seconds passed as int for the Python SDK
  - Fixed api_key=token_provider -> api_key=token_provider() in the video app

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
The instruction pages told learners to run "python setup/check_env.py --task N"
"from the Labfiles/<lab> folder", but the pages put learners in the Python/
subfolder: Getting started has them open Labfiles/<lab>/Python in VS Code and
open the integrated terminal there, and that is also where the venv and .env
live. Running the command as written fails with:

  can't open file '...\Python\setup\check_env.py': [Errno 2] No such file

Inherited from the reference repo (mslearn-ai-agents) when this template was
ported, and caught by the parallel consolidation in
mslearn-ai-information-extraction.

Fixes, applied consistently across both labs:
  - All 9 invocations: setup/check_env.py -> ../setup/check_env.py
  - Surrounding prose now names the Python folder, not the lab root, so it
    matches where learners actually are (no undocumented cd required)
  - Both check_env.py docstrings updated and now state the working directory
  - Both Solution READMEs disambiguate the starter Python/ folder from
    Solution/Python/, where ../setup/ would not have resolved either

Verified by executing every command from the directory each page implies:
all 9 now run (exit 1 = correct "missing keys" report) instead of failing
with exit 2. Also re-ran py_compile, ASCII scan, check_env --help/--task 1-3,
link resolution, and snippet fidelity (22/22).

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
check_env.py imported python-dotenv unconditionally, but that package is only
installed by "pip install -r requirements.txt" into the lab's labenv virtual
environment. The preflight check is meant to run BEFORE that install (and the
task pages call it from their setup callout, before Getting started has
necessarily been completed), so on a clean interpreter it died with:

  ModuleNotFoundError: No module named 'dotenv'

Even "--help" failed, which the validation gate requires to run cleanly.

Inherited from the reference repo (mslearn-ai-agents); reported by the parallel
consolidation in mslearn-ai-studio.

Fix: wrap the import in try/except ModuleNotFoundError and fall back to a small
stdlib .env parser. Verified byte-identical to python-dotenv's dotenv_values
across quoted, single-quoted, empty, "export "-prefixed, inline-comment,
comment-line and bare-key-without-"=" cases.

Verified from cwd=Python/ (the directory the pages put learners in) on BOTH a
clean interpreter with no dotenv and the venv interpreter with dotenv present:
--help exits 0 and --task 1/2/3 behave identically on both. Also re-ran
py_compile and the ASCII scan (14 files).

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
NOTE: this commit intentionally edits a SHARED ROOT FILE (index.md), which is
outside the otherwise purely-additive scope of this PR. It is a deliberate,
user-approved change requested via the coordinating session.

index.md
  The Liquid loop listed every page under /Instructions/Exercises that has a
  lab.title, so the new consolidated pages were auto-listed alongside the
  originals - including two identically-titled "Getting started" entries.
  Filter the loop on status so draft pages are skipped:

    {% if activity.lab.title %}
    -> {% if activity.lab.title and activity.lab.status != 'draft' %}

  A missing status is treated as publishable, not draft, so legacy pages
  without the field are unaffected. Verified by rendering the real condition
  in a Liquid engine against the repo's real frontmatter plus synthetic
  no-status / released / draft pages: the 4 released exercises and both
  synthetic non-draft pages still list; all 10 consolidated drafts drop out.

check_env.py (both labs)
  A .env saved by some Windows editors carries a UTF-8 BOM, which python-dotenv
  glues onto the first key name ("\ufeffOPENAI_ENDPOINT"). That made the first
  key in the file report as MISSING even when correctly set - demonstrated, then
  fixed by stripping the BOM from keys. The stdlib fallback already handled this
  via utf-8-sig; now both code paths agree. Also return {} for an unreadable
  .env instead of raising.

Solution/README.md (both labs)
  Make the annotation-style "check_env.py --task N" bullet an explicit runnable
  command with the correct relative path, so no occurrence anywhere in the repo
  is left un-prefixed.

Verified: BOM'd .env now passes on both the venv (real dotenv) and clean
(fallback) interpreters, with no regression on a normal .env; py_compile +
ASCII scan (14 files); repo-wide grep for un-prefixed setup/check_env.py = 0.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
check_env.py (both labs)
  Move the stdlib .env parser out of the ImportError branch to a module-level
  _parse_env_file(), so it can be imported and differential-tested directly
  against python-dotenv instead of only being reachable when dotenv is absent.

  That test immediately found a real divergence the nested version hid: a
  QUOTED value with a trailing comment.

      INLINE_QUOTED="value" # c   real: 'value'   previous fallback: '"value"'
      INLINE_SINGLE='value' # c   real: 'value'   previous fallback: "'value'"

  The value was neither fully quoted nor unquoted, so it fell through to the
  comment-stripping branch and kept its quotes. Now quoted values are read up to
  their closing quote, which both drops a trailing comment and preserves a "#"
  inside the quotes.

  Differential test: 21-line corpus (plain, double/single quoted, export,
  export+quoted, inline comments, # inside quotes, no-space #, empty, empty
  quoted, spaced key, trailing space, URLs, = in value, bare key with no =)
  compared key-by-key against python-dotenv. 19 keys, 0 divergences. Both
  dump modes assert find_spec('dotenv') is/is not None first, so a "no dotenv"
  result can never be silently produced by an interpreter that has it.

index.md
  Harden the draft filter against status casing and add a comment documenting
  the contract:

      {% assign lab_status = activity.lab.status | default: '' | downcase %}
      {% if activity.lab.title and lab_status != 'draft' %}

  'Draft' and 'DRAFT' are now hidden too, while a missing status still lists
  (empty string != 'draft'), so existing content is unaffected by construction.

  Verified by rendering the real loop with python-liquid, registering a stand-in
  for Jekyll's where_exp filter rather than emulating it in Python, against the
  repo's actual frontmatter plus edge cases: no-status, released, empty status,
  draft/Draft/DRAFT, a title-less page, and a page outside the exercises tree.
  Asserts the 4 released exercises list, 0 drafts leak, 0 non-drafts dropped,
  0 duplicate titles, and that where_exp does not reach outside the tree.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Extending the differential test to backslash escapes and unterminated quotes
found four more divergences in the stdlib .env fallback:

  ESCAPED_QUOTE="a\"b"       real 'a"b'    fallback 'a\'   (stopped at the escape)
  ESCAPED_BACKSLASH="a\\b"   real 'a\b'    fallback 'a\\b' (no unescaping)
  ESCAPED_NEWLINE="a\nb"     real newline  fallback 'a\nb' (literal)
  UNTERMINATED="abc          real discards fallback 'abc'  (invented a value)

Double-quoted values are now scanned character by character honoring backslash
escapes, so an escaped quote does not terminate the value and \n \r \t \" \' \\
are unescaped. Single quotes stay literal. An unterminated quote discards the
entry, matching python-dotenv, rather than inventing a value the venv path
would never produce.

The already-reported quoted-with-trailing-comment case is confirmed to produce
a bare value, not merely a comment-stripped one:

  INLINE_QUOTED="value" # comment  ->  'value'   (both paths)

Differential test is now per-case: each case gets its OWN .env file, because an
unterminated quote makes python-dotenv swallow following lines, which silently
contaminated four unrelated keys in the single-file corpus. 28 cases, 27
comparisons, 0 unexpected divergences. Both dump modes assert
find_spec('dotenv') is/is not None before trusting a result.

One divergence is deliberate and allowlisted: with a BOM, the fallback reads
utf-8-sig and yields BOM_FIRST_KEY while python-dotenv yields
"\ufeffBOM_FIRST_KEY". load_values() strips the BOM from BOTH paths, so
downstream behaviour is identical - the fallback is simply the more correct of
the two.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
The preflight must report what the learner's app actually sees. Where
python-dotenv's behaviour is the thing that breaks the learner, being "more
correct than dotenv" is the bug, not the fix.

UTF-8 BOM: detect and explain instead of normalizing
  A BOM'd .env genuinely does not work. Verified against the shipped app code,
  not just the checker:

      load_dotenv(); os.getenv("OPENAI_ENDPOINT") -> None     # app fails
      (control, same file without a BOM)          -> the URL  # app works

  The previous commit stripped the BOM inside load_values() so the key list
  read sensibly, which made check_env exit 0 for a file that cannot run the
  lab - a false positive, strictly worse than the false negative it replaced.
  Now _file_has_bom() reports it as [PROBLEM], main() exits non-zero, and the
  guidance says how to re-save (VS Code status bar > Save with Encoding >
  UTF-8, not UTF-8 with BOM). The key-name normalization is kept ONLY so both
  parser paths print the same key list, and is commented as not being the fix.

Unterminated quotes: discard-then-resume, with match-only lookahead
  Measured, not assumed. python-dotenv discards the malformed statement and
  resumes; how much it consumes depends on whether a later line closes the
  quote:

      no later quote  -> only that setting is dropped, the rest survive
      later quote     -> the settings in between are swallowed and lost

  The parser previously discarded line-by-line, which was right for the first
  shape and wrong for the second, and an intermediate version broke to EOF,
  which was wrong for the first. It is now a whole-text scanner that looks
  ahead and only advances on a match, reproducing both shapes exactly.

  Detection uses the local scanner even when python-dotenv is installed,
  because dotenv reports these malformations to stderr rather than returning
  them - so on the venv path the cause would otherwise be invisible and the
  learner would just see unrelated MISSING keys. The message names the
  offending line and key, and explains the counterintuitive part: a setting on
  a later line can look completely correct and still read as empty.

Verification
  - Per-case corpus (one construct per file): 43 cases, 56 comparisons.
  - Combined/adjacency fixtures (malformed line followed by valid ones), since
    swallowing is only observable when there are later keys to swallow.
  - Positional adjacency matrix: 12 fixtures placing the malformed line first,
    middle and last, with blanks, comments, a later quoted value, two
    unterminated in a row, and one swallowing the real lab keys. 0 divergences.
  - End-to-end: the same .env through the shipped app and the preflight, on
    both the venv and a clean interpreter - identical output, identical exit
    codes, and the preflight reports exactly what the app can read.
  - 7-scenario exit-code matrix, both interpreters, all as intended.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Confirmed divergence, and it was the false-positive class. python-dotenv is
escape-aware while a value PARSES, but raw while RECOVERING from a quote that
never closes:

    A=1
    B="unterminated
    C=has \" escaped only
    D=4

    dotenv   -> A, D          the \" ends the broken statement, swallowing C
    previous -> A, C, D       escape-aware recovery skipped it

The parser's scan was escape-aware throughout, which is right for the success
case and wrong for recovery. With a required key on the swallowed line the
preflight would have reported it as set while the app could not read it - a
false positive, the class already rejected for the BOM.

Neither pure strategy works: raw-everywhere mangles ESCAPED="say \"hi\" there",
escape-aware-everywhere misses the recovery. Now two-pass - scan escape-aware
first, and only if nothing closes the value anywhere, retry raw to find where
dotenv's broken statement ends, then resume on the following line.

Verified by full regression across three fixture layers, since this touches the
recovery path every earlier suite exercised:

  - per-case syntax (one construct per file)
  - combined/adjacency (malformed line followed by valid keys)
  - positional (same malformation placed first, middle and last)
  - context-exemption (apostrophe inside a double-quoted value, a comment, an
    unquoted value; quote inside a single-quoted value) - nothing is exempt
  - escape-aware-parse vs raw-recovery, each with a control fixture

  61 fixtures, 94 comparisons, 0 unexpected divergences.

Also verified end-to-end: the same .env through the shipped app and the
preflight, on both the venv and a clean interpreter. Both keys correctly report
MISSING, matching what the app can read, and the 8-scenario exit-code matrix
agrees on both interpreters.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Follow-up to the raw-recovery fix. The escape-skip in the close-quote scan was
guarded by quote == '"', so recovery from an unterminated SINGLE quote was not
escape-aware and diverged:

    A=1 / B='unterminated / C=has \' escaped / D='real close' / E=5
      dotenv   -> A, E        skips the escaped candidate, closes at D
      previous -> A, D, E     closed at the escaped quote on C

A required key on D would have been reported present while the app never
receives it - the same false-positive class as before. The equivalent
double-quote fixture already passed, because the two-pass scan is escape-aware
for '"' on the first pass.

So the close-quote search now honors backslash escapes for BOTH quote styles.
Value content keeps the documented asymmetry: double quotes unescape, single
quotes stay literal.

That change surfaced one more real difference, found by a fixture added while
fixing it: inside single quotes dotenv DOES unescape the delimiter itself.

    'a\'b'  -> a'b     (backslash dropped)
    'a\"b'  -> a\"b    (backslash kept - not the delimiter)

Handled explicitly rather than treating all single-quote escapes as literal.

Verification
  - Regression: 67 fixtures, 102 comparisons, 0 unexpected divergences.
  - Fixture validation by MUTATION: every fixture is run against five
    deliberately broken parsers (raw-only recovery, no raw fallback,
    double-quote-only escape guard, no swallow, no BOM strip) to measure which
    fixtures actually catch which bug. 18 of 67 fixtures discriminate at least
    one mutant, the other 49 act as controls, and every mutant is caught by at
    least one fixture - no blind spots. This exists because several earlier
    "0 divergences" results in this work were true of the corpus and false of
    the behaviour.
  - 9-scenario exit-code matrix, both interpreters, including escaped-quote
    recovery in each quote style.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Measured against python-dotenv rather than reasoned about. The previous rule -
"single quotes are literal except the delimiter" - was wrong for one case:

    'say \' inside'   -> say ' inside    (delimiter decoded)   already correct
    'a\\b'            -> a\b             (backslash ALSO decoded)  was a\\b
    'raw \n stays'    -> raw \n stays    (literal)             already correct
    'a\"b'            -> a\"b            (literal)             already correct

So single quotes decode exactly two pairs, \' and \\, and leave everything else
alone; double quotes decode the full set. The three-way branch is replaced by
one routine driven by a per-quote decoder map, which states the asymmetry in
one place instead of implying it through control flow.

The close-quote SEARCH still honors escapes for both styles - that is separate
from which pairs get decoded, and is what the previous commit fixed.

Verification
  - Escape-set matrix: 9 fixtures covering \' \\ \n \t \" against both quote
    styles, 0 divergences.
  - Regression: 68 fixtures, 102 comparisons, 0 unexpected divergences (the
    only allowlisted ones remain the BOM key-name display, where the verdict
    is identical).
  - Fixture validation by mutation, now 7 mutants including two that target
    this change specifically - single quotes fully literal, and single quotes
    decoding the full double-quote set. Both are caught. 21 of 68 fixtures
    discriminate at least one mutant, no mutant is uncaught.
  - 9-scenario exit-code matrix on both interpreters, including escaped-quote
    recovery in each quote style.

Two mutants initially reported "pattern NOT FOUND" because the anchors were
mis-escaped; the harness treats an unapplied mutant as a blind spot rather than
a pass, which is what surfaced it. Anchors are now built with chr(92).

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Three items, each measured rather than assumed.

1. Missing control-character escapes
   python-dotenv decodes \a \b \f \v inside double quotes; the map had only
   \n \r \t \" \' \\, so four escapes came back literal. Fixed, and confirmed
   that \0 \x \z are NOT decoded by dotenv and correctly stay literal in both
   quote styles. 26-fixture escape matrix (every escape letter x both quote
   styles): 0 divergences.

2. Placeholder-list rot
   PLACEHOLDERS was an exact-match list that has to be kept in step with
   .env.example by hand; if the example file is edited, an unedited .env starts
   reporting "You're ready" - a false positive. The values shipped in
   .env.example are now unioned into the placeholder set at runtime, so the two
   cannot drift. Verified by simulating the rot: .env.example rewritten with
   values absent from the hard-coded list, copied to .env unedited, still
   correctly reports not-ready on both interpreters, while genuine values still
   pass.

   The durable assertion is behavioural rather than structural - "copy
   .env.example to .env unedited and confirm not-ready" - because a lab may
   legitimately pre-fill a real value one day. That constraint is documented on
   the function.

3. INTENTIONAL DIVERGENCE marker
   The BOM key-name normalization is deliberate and will show up as a
   difference in any parity check against python-dotenv. It is now labelled in
   the code with the reasoning, so a future maintainer does not "fix" it and
   silently remove the protection - safety there comes from _file_has_bom()
   reporting it and main() exiting non-zero, not from matching the library.

Also strengthened the app-vs-preflight harness from readiness to VALUE equality:
comparing exit codes cannot catch a truncated or mis-unescaped value, since the
key is present either way. 20 fixtures covering escapes, quoting, inline
comments, spacing and URLs now assert that every value the preflight parses is
byte-identical to what the app receives via load_dotenv/os.environ, on both the
venv and clean-interpreter paths. 0 mismatches.

Verification: 10-scenario exit matrix on both interpreters; behavioural
placeholder assertion across 2 labs x 3 tasks x 2 interpreters; _parse_env_text
byte-identical between the two labs; py_compile and ASCII scan on all 14 files.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
The previous commit unioned ALL .env.example values into the placeholder set.
That closes the rot false-positive but opens a false NEGATIVE: if an example
file ever ships a real default the learner is meant to keep, keeping it is
reported as MISSING and a correctly-configured learner is blocked.

These labs ship only placeholders today, so nothing was broken - but a
documented constraint is not a guard, so it is now structural:

  EXAMPLE_REAL_DEFAULTS  keys whose .env.example value is a real default to
                         KEEP. Those values are excluded from the union.
                         Empty here, declared next to PLACEHOLDERS so it is
                         visible the moment someone pre-fills a working value.

An allowlist alone would still miss a rot to a novel token with no recognisable
template wording, so endpoints are additionally validated by shape: a *_ENDPOINT
or *_URL value that is not URL-shaped is template text or a wrong paste,
whatever it says. That is a property of the setting rather than a guess about
how the placeholder happens to be worded, and it also catches a learner pasting
a resource NAME where the endpoint belongs.

Verified with the full matrix, both labs, both interpreters:

  unedited .env.example -> .env                        blocked
  rot to hyphenated spellings, stale list              blocked
  rot to a novel token (PASTE_YOUR_ENDPOINT_HERE)      blocked by the URL rule
  correctly filled .env keeping a real pre-filled
  default (declared in EXAMPLE_REAL_DEFAULTS)          passes - no over-blocking

The last row is the one a blanket union fails; it is exercised by temporarily
shipping a real default and declaring it, then restoring the file.

Also added "non-URL endpoint" to the exit-code matrix, now 10 scenarios on both
interpreters, plus py_compile and the ASCII scan on all 14 files.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Keyword lists for placeholder detection leak: of four realistic template
tokens, only PASTE_YOUR_ENDPOINT_HERE was caught by a "your" keyword -
ENDPOINT_GOES_HERE, FILL_IN_ENDPOINT and __ENDPOINT__ all sailed through.
Template wording is unbounded; the SHAPE of a setting is not.

So validation is now by shape, in _wrong_shape():

  *_ENDPOINT / *_URL   must look like a URL
  everything else      must be a bare name, not a URL

This catches something no placeholder list can: a real value pasted into the
wrong setting. That matters here because Lab A has three endpoint-ish values
(the Azure OpenAI v1 endpoint, the Content Understanding resource endpoint, and
the project endpoint the instructions warn twice NOT to use) alongside two bare
names, so confusing them is a realistic learner error rather than a hypothetical.

The output now separates "not filled in" from "filled in wrongly":

  [WRONG] OPENAI_ENDPOINT
    This doesn't look like an endpoint - it must start with https:// . Check
    you haven't pasted a resource name or a model deployment name here.
  [WRONG] MODEL_DEPLOYMENT_NAME
    This should be a bare name, not a URL. Check you haven't pasted an
    endpoint here.

Guarded against over-blocking in the other direction, since a shape rule that
is too strict blocks a ready learner:

  http://localhost:8080/openai/v1/   passes (local proxy)
  my-gpt-5.2-prod                    passes (a name that contains dots/dashes)
  normal correct configuration       passes

Verified on both interpreters: 9 shape cases (4 template tokens, 2 wrong-paste
directions, 3 over-blocking guards), plus the 4-row placeholder matrix across
both labs - unedited example, rot to hyphenated spellings, rot to a novel
token, and a correctly-filled .env keeping a declared real default. 0 failures
across all 8 placeholder rows. py_compile and ASCII scan on all 14 files;
main() bodies byte-identical between the two labs.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant