dockerfile-analyzer 0.3.25__tar.gz → 0.4.0__tar.gz

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 (22) hide show
  1. {dockerfile_analyzer-0.3.25 → dockerfile_analyzer-0.4.0}/Cargo.lock +1 -1
  2. {dockerfile_analyzer-0.3.25 → dockerfile_analyzer-0.4.0}/Cargo.toml +1 -1
  3. {dockerfile_analyzer-0.3.25 → dockerfile_analyzer-0.4.0}/PKG-INFO +1 -1
  4. {dockerfile_analyzer-0.3.25 → dockerfile_analyzer-0.4.0}/README.md +106 -1
  5. {dockerfile_analyzer-0.3.25 → dockerfile_analyzer-0.4.0}/dockerfile_analyzer.pyi +42 -0
  6. {dockerfile_analyzer-0.3.25 → dockerfile_analyzer-0.4.0}/src/analyzer.rs +393 -8
  7. {dockerfile_analyzer-0.3.25 → dockerfile_analyzer-0.4.0}/src/constants.rs +1 -0
  8. {dockerfile_analyzer-0.3.25 → dockerfile_analyzer-0.4.0}/src/lib.rs +5 -0
  9. {dockerfile_analyzer-0.3.25 → dockerfile_analyzer-0.4.0}/src/models.rs +112 -0
  10. dockerfile_analyzer-0.4.0/tests/test_stages.py +192 -0
  11. {dockerfile_analyzer-0.3.25 → dockerfile_analyzer-0.4.0}/.cargo/config.toml +0 -0
  12. {dockerfile_analyzer-0.3.25 → dockerfile_analyzer-0.4.0}/.github/dependabot.yml +0 -0
  13. {dockerfile_analyzer-0.3.25 → dockerfile_analyzer-0.4.0}/.github/workflows/CI.yml +0 -0
  14. {dockerfile_analyzer-0.3.25 → dockerfile_analyzer-0.4.0}/.gitignore +0 -0
  15. {dockerfile_analyzer-0.3.25 → dockerfile_analyzer-0.4.0}/LICENSE +0 -0
  16. {dockerfile_analyzer-0.3.25 → dockerfile_analyzer-0.4.0}/example.py +0 -0
  17. {dockerfile_analyzer-0.3.25 → dockerfile_analyzer-0.4.0}/py.typed +0 -0
  18. {dockerfile_analyzer-0.3.25 → dockerfile_analyzer-0.4.0}/pyproject.toml +0 -0
  19. {dockerfile_analyzer-0.3.25 → dockerfile_analyzer-0.4.0}/requirements-dev.txt +0 -0
  20. {dockerfile_analyzer-0.3.25 → dockerfile_analyzer-0.4.0}/src/parse_utils.rs +0 -0
  21. {dockerfile_analyzer-0.3.25 → dockerfile_analyzer-0.4.0}/tests/fixtures/multistage.Dockerfile +0 -0
  22. {dockerfile_analyzer-0.3.25 → dockerfile_analyzer-0.4.0}/tests/test_analyze_dockerfile.py +0 -0
@@ -14,7 +14,7 @@ dependencies = [
14
14
 
15
15
  [[package]]
16
16
  name = "dockerfile-analyzer"
17
- version = "0.3.25"
17
+ version = "0.4.0"
18
18
  dependencies = [
19
19
  "docker-image",
20
20
  "parse-dockerfile",
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "dockerfile-analyzer"
3
- version = "0.3.25"
3
+ version = "0.4.0"
4
4
  edition = "2024"
5
5
  readme = "README.md"
6
6
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dockerfile-analyzer
3
- Version: 0.3.25
3
+ Version: 0.4.0
4
4
  Classifier: Programming Language :: Rust
5
5
  Classifier: Programming Language :: Python :: Implementation :: CPython
6
6
  License-File: LICENSE
@@ -10,9 +10,28 @@ This Python package analyzes a Dockerfile and returns detailed analysis informat
10
10
  - Instructions statistics
11
11
  - Environment variables, labels, and arguments
12
12
  - Exposed ports
13
+ - A per-stage breakdown (`stages`, `final_stage_info`): each stage's base image, parent stage, `--platform`, and its own `ARG`, `ENV`, `LABEL`, `EXPOSE`, `USER`, and `WORKDIR` values
13
14
 
14
15
  Image references that contain `$` are not expanded. Other image references are lowercased. `EXPOSE` values are returned as written, including protocol suffixes and ranges. A later `ARG NAME` without a default keeps an earlier default.
15
16
 
17
+ The top-level `args`, `env_vars`, `labels`, and `exposed_ports` are merged across all stages (and `args` also includes global `ARG`s declared before the first `FROM`), so a later stage's value for a key replaces an earlier one. To see which stage set what, use the per-stage breakdown described below.
18
+
19
+ ## Per-stage breakdown
20
+
21
+ `analysis.stages` is a list of `Stage` objects in Dockerfile order. Each `Stage` has:
22
+
23
+ | Attribute | Description |
24
+ | --- | --- |
25
+ | `index` | Zero-based position of the stage. |
26
+ | `name` | The lowercased `AS` name, or `None` when the stage is unnamed. |
27
+ | `base_image` | The `FROM` reference, normalized like `images` (lowercased unless it contains `$`). |
28
+ | `parent_stage` | The name of the earlier stage this `FROM` builds on, or `None`. Docker only resolves earlier stages, so a name defined later, or an unknown name, is treated as an image and has no parent. |
29
+ | `platform` | The `FROM --platform` value as written, or `None`. |
30
+ | `args`, `env_vars`, `labels`, `exposed_ports` | Values set by this stage's own instructions. Global `ARG`s before the first `FROM` are not attributed to any stage, and nothing is inherited from a parent stage. |
31
+ | `user`, `workdir` | The last `USER` and `WORKDIR` in this stage, or `None`. |
32
+
33
+ `Stage` has `to_dict()` and a readable `repr()`. `analysis.final_stage_info` returns the last `Stage`, or `None` when there are no stages. The existing `analysis.final_stage` is unchanged and still returns only the last stage's name (or `None` when it is unnamed). `analysis.to_dict()` includes the stages under a `stages` key.
34
+
16
35
  The code leverages the highly useful Rust crates `parse-dockerfile` and `docker-image`.
17
36
 
18
37
  ## Example
@@ -88,6 +107,12 @@ print(analysis.final_stage) # None
88
107
  # The second ARG VERSION has no default, so 1.0.0 is kept.
89
108
  print(analysis.args["VERSION"]) # 1.0.0
90
109
 
110
+ # Per-stage values. Global ARGs before the first FROM belong to no stage.
111
+ print(analysis.stages[0].args) # {}
112
+ print(analysis.stages[2].parent_stage) # build
113
+ final = analysis.final_stage_info
114
+ print(final.index, final.user, final.exposed_ports) # 4 nonroot ['8080/tcp', '9090/udp']
115
+
91
116
  print(json.dumps(analysis.to_dict(), indent=2))
92
117
  ```
93
118
 
@@ -99,6 +124,9 @@ ghcr.io
99
124
  ['debug']
100
125
  None
101
126
  1.0.0
127
+ {}
128
+ build
129
+ 4 nonroot ['8080/tcp', '9090/udp']
102
130
 
103
131
  ```json
104
132
  {
@@ -205,7 +233,84 @@ None
205
233
  },
206
234
  "env_vars": {
207
235
  "APP_VERSION": "$VERSION"
208
- }
236
+ },
237
+ "stages": [
238
+ {
239
+ "index": 0,
240
+ "name": "tools",
241
+ "base_image": "alpine:${ALPINE_VERSION}",
242
+ "parent_stage": null,
243
+ "platform": null,
244
+ "args": {},
245
+ "env_vars": {},
246
+ "labels": {},
247
+ "exposed_ports": [],
248
+ "user": null,
249
+ "workdir": null
250
+ },
251
+ {
252
+ "index": 1,
253
+ "name": "build",
254
+ "base_image": "golang:${GO_VERSION}-alpine",
255
+ "parent_stage": null,
256
+ "platform": null,
257
+ "args": {},
258
+ "env_vars": {},
259
+ "labels": {},
260
+ "exposed_ports": [],
261
+ "user": null,
262
+ "workdir": "/src"
263
+ },
264
+ {
265
+ "index": 2,
266
+ "name": "assets",
267
+ "base_image": "build",
268
+ "parent_stage": "build",
269
+ "platform": null,
270
+ "args": {},
271
+ "env_vars": {},
272
+ "labels": {},
273
+ "exposed_ports": [],
274
+ "user": null,
275
+ "workdir": null
276
+ },
277
+ {
278
+ "index": 3,
279
+ "name": "debug",
280
+ "base_image": "alpine:${ALPINE_VERSION}",
281
+ "parent_stage": null,
282
+ "platform": null,
283
+ "args": {},
284
+ "env_vars": {},
285
+ "labels": {},
286
+ "exposed_ports": [],
287
+ "user": null,
288
+ "workdir": null
289
+ },
290
+ {
291
+ "index": 4,
292
+ "name": null,
293
+ "base_image": "gcr.io/distroless/static-debian12:nonroot@sha256:2c8e1f5d9a3b7e6c4d1f0a8b5e2c9d7f3a6b1c4e8d2f5a9b3c7e1d4f6a8b0c2e",
294
+ "parent_stage": null,
295
+ "platform": null,
296
+ "args": {
297
+ "VERSION": null
298
+ },
299
+ "env_vars": {
300
+ "APP_VERSION": "$VERSION"
301
+ },
302
+ "labels": {
303
+ "org.opencontainers.image.title": "app",
304
+ "org.opencontainers.image.version": "${VERSION}"
305
+ },
306
+ "exposed_ports": [
307
+ "8080/tcp",
308
+ "9090/udp"
309
+ ],
310
+ "user": "nonroot",
311
+ "workdir": null
312
+ }
313
+ ]
209
314
  }
210
315
  ```
211
316
 
@@ -19,6 +19,11 @@ def analyze_dockerfile(body: str) -> Analysis:
19
19
  when it is unnamed. A later ``ARG NAME`` without a value keeps an
20
20
  earlier default.
21
21
 
22
+ ``args``, ``labels``, ``env_vars``, and ``exposed_ports`` are merged
23
+ across all stages. ``stages`` lists each stage with the values it sets
24
+ itself, and ``final_stage_info`` is the last ``Stage`` (None when
25
+ there are no stages).
26
+
22
27
  Raises:
23
28
  ValueError: If the dockerfile content is empty or invalid
24
29
 
@@ -32,6 +37,16 @@ def analyze_dockerfile(body: str) -> Analysis:
32
37
  from typing import Dict, List, Optional, Any
33
38
 
34
39
  class Analysis:
40
+ """Analysis of one Dockerfile.
41
+
42
+ ``args``, ``labels``, ``env_vars``, and ``exposed_ports`` are merged across
43
+ all stages (``args`` also includes global ``ARG``s before the first
44
+ ``FROM``); a later stage's value for a key replaces an earlier one. Use
45
+ ``stages`` for the values each stage sets. ``final_stage`` is the last
46
+ stage's name (None when unnamed); ``final_stage_info`` is the last
47
+ ``Stage`` object, or None when there are no stages.
48
+ """
49
+
35
50
  num_stages: int
36
51
  images: List[Image]
37
52
  stage_names: List[str]
@@ -46,6 +61,33 @@ class Analysis:
46
61
  args: Dict[str, Optional[str]]
47
62
  labels: Dict[str, str]
48
63
  env_vars: Dict[str, str]
64
+ stages: List[Stage]
65
+
66
+ @property
67
+ def final_stage_info(self) -> Optional[Stage]: ...
68
+ def to_dict(self) -> Dict[str, Any]: ...
69
+ def __repr__(self) -> str: ...
70
+
71
+ class Stage:
72
+ """One build stage, from its ``FROM`` line to the next ``FROM``.
73
+
74
+ Values are the stage's own. Nothing is inherited from a parent stage, and
75
+ global ``ARG``s declared before the first ``FROM`` are not attributed to
76
+ any stage. ``parent_stage`` is set only when ``FROM`` names an earlier
77
+ stage; a later or unknown name is treated as an image.
78
+ """
79
+
80
+ index: int
81
+ name: Optional[str]
82
+ base_image: str
83
+ parent_stage: Optional[str]
84
+ platform: Optional[str]
85
+ args: Dict[str, Optional[str]]
86
+ env_vars: Dict[str, str]
87
+ labels: Dict[str, str]
88
+ exposed_ports: List[str]
89
+ user: Optional[str]
90
+ workdir: Optional[str]
49
91
 
50
92
  def to_dict(self) -> Dict[str, Any]: ...
51
93
  def __repr__(self) -> str: ...
@@ -24,6 +24,8 @@ struct InstructionFacts {
24
24
  args: BTreeMap<String, Option<String>>,
25
25
  labels: BTreeMap<String, String>,
26
26
  env_vars: BTreeMap<String, String>,
27
+ user: Option<String>,
28
+ workdir: Option<String>,
27
29
  }
28
30
 
29
31
  enum FromTarget {
@@ -35,7 +37,12 @@ pub fn analyze_dockerfile(body: &str) -> Result<models::Analysis, Box<dyn Error>
35
37
  let dockerfile = parse(body)?;
36
38
  let stages: Vec<_> = dockerfile.stages().collect();
37
39
  let stage_info = extract_stage_info(&stages);
38
- let facts = collect_instructions(&dockerfile.instructions, &stage_info.ordered_names);
40
+ let facts = collect_instructions(&dockerfile.instructions, &stage_info.ordered_names, true);
41
+ let stage_details = stages
42
+ .iter()
43
+ .enumerate()
44
+ .map(|(index, stage)| build_stage(index, stage, &stage_info.ordered_names))
45
+ .collect();
39
46
  let multistage_analysis = analyze_multistage(
40
47
  stages.len(),
41
48
  &stage_info.images,
@@ -62,9 +69,43 @@ pub fn analyze_dockerfile(body: &str) -> Result<models::Analysis, Box<dyn Error>
62
69
  args: facts.args,
63
70
  labels: facts.labels,
64
71
  env_vars: facts.env_vars,
72
+ stages: stage_details,
65
73
  })
66
74
  }
67
75
 
76
+ /// Describe one stage using only the instructions inside it.
77
+ ///
78
+ /// The `FROM` instruction is not part of `stage.instructions`, so global
79
+ /// `ARG`s before the first `FROM` never land in stage 0.
80
+ fn build_stage(index: usize, stage: &Stage, ordered_names: &[Option<String>]) -> models::Stage {
81
+ let facts = collect_instructions(stage.instructions, ordered_names, false);
82
+ let base_image = normalize_image_ref(&stage.from.image.value);
83
+ let parent_stage = earlier_stage_named(&base_image, &ordered_names[..index]);
84
+ models::Stage {
85
+ index,
86
+ name: ordered_names[index].clone(),
87
+ base_image,
88
+ parent_stage,
89
+ platform: flag_value(&stage.from.options, constants::PLATFORM),
90
+ args: facts.args,
91
+ env_vars: facts.env_vars,
92
+ labels: facts.labels,
93
+ exposed_ports: facts.exposed_ports.into_iter().collect(),
94
+ user: facts.user,
95
+ workdir: facts.workdir,
96
+ }
97
+ }
98
+
99
+ /// Docker resolves a `FROM` name against earlier stages only. A name defined
100
+ /// later, or not at all, is an image reference.
101
+ fn earlier_stage_named(image: &str, earlier_names: &[Option<String>]) -> Option<String> {
102
+ earlier_names
103
+ .iter()
104
+ .flatten()
105
+ .find(|name| name.as_str() == image)
106
+ .cloned()
107
+ }
108
+
68
109
  fn analyze_multistage(
69
110
  num_stages: usize,
70
111
  images: &BTreeSet<String>,
@@ -125,6 +166,7 @@ fn extract_stage_info(stages: &[Stage]) -> StageInfo {
125
166
  fn collect_instructions(
126
167
  instructions: &[Instruction],
127
168
  ordered_stage_names: &[Option<String>],
169
+ preserve_arg_defaults: bool,
128
170
  ) -> InstructionFacts {
129
171
  let mut facts = InstructionFacts {
130
172
  copy_from_stages: BTreeSet::new(),
@@ -137,6 +179,8 @@ fn collect_instructions(
137
179
  args: BTreeMap::new(),
138
180
  labels: BTreeMap::new(),
139
181
  env_vars: BTreeMap::new(),
182
+ user: None,
183
+ workdir: None,
140
184
  };
141
185
 
142
186
  for instruction in instructions {
@@ -146,10 +190,14 @@ fn collect_instructions(
146
190
 
147
191
  match instruction {
148
192
  Instruction::Arg(arg) => {
149
- parse_utils::merge_kv_pairs(
150
- &mut facts.args,
151
- parse_utils::parse_kv_pairs(arg.arguments.value.as_ref(), false),
152
- );
193
+ let pairs = parse_utils::parse_kv_pairs(arg.arguments.value.as_ref(), false);
194
+ if preserve_arg_defaults {
195
+ // A later stage's bare `ARG NAME` redeclares a global default.
196
+ parse_utils::merge_kv_pairs(&mut facts.args, pairs);
197
+ } else {
198
+ // Inside one stage, a later bare `ARG NAME` clears the default.
199
+ facts.args.extend(pairs);
200
+ }
153
201
  }
154
202
  Instruction::Env(env) => {
155
203
  insert_required_values(
@@ -168,6 +216,12 @@ fn collect_instructions(
168
216
  .exposed_ports
169
217
  .extend(expose.arguments.iter().map(|port| port.value.to_string()));
170
218
  }
219
+ Instruction::User(user) => {
220
+ facts.user = Some(user.arguments.value.to_string());
221
+ }
222
+ Instruction::Workdir(workdir) => {
223
+ facts.workdir = Some(workdir.arguments.value.to_string());
224
+ }
171
225
  Instruction::Copy(copy) => {
172
226
  record_from_flag(
173
227
  &copy.options,
@@ -206,7 +260,7 @@ fn record_from_flag(
206
260
  stages: &mut BTreeSet<String>,
207
261
  images: &mut BTreeMap<String, models::Image>,
208
262
  ) {
209
- let Some(raw) = from_flag_value(options) else {
263
+ let Some(raw) = flag_value(options, constants::FROM) else {
210
264
  return;
211
265
  };
212
266
  match resolve_from_target(&raw, ordered_stage_names) {
@@ -221,11 +275,11 @@ fn record_from_flag(
221
275
  }
222
276
  }
223
277
 
224
- fn from_flag_value(options: &[Flag]) -> Option<String> {
278
+ fn flag_value(options: &[Flag], name: &str) -> Option<String> {
225
279
  options.iter().find_map(|flag| {
226
280
  flag.name
227
281
  .value
228
- .eq_ignore_ascii_case(constants::FROM)
282
+ .eq_ignore_ascii_case(name)
229
283
  .then(|| flag.value.as_ref().map(|value| value.value.to_string()))
230
284
  .flatten()
231
285
  })
@@ -446,6 +500,31 @@ mod tests {
446
500
  }
447
501
  }
448
502
 
503
+ fn stage(
504
+ index: usize,
505
+ name: Option<&str>,
506
+ base_image: &str,
507
+ parent_stage: Option<&str>,
508
+ ) -> models::Stage {
509
+ models::Stage {
510
+ index,
511
+ name: name.map(str::to_string),
512
+ base_image: base_image.to_string(),
513
+ parent_stage: parent_stage.map(str::to_string),
514
+ platform: None,
515
+ args: BTreeMap::new(),
516
+ env_vars: BTreeMap::new(),
517
+ labels: BTreeMap::new(),
518
+ exposed_ports: vec![],
519
+ user: None,
520
+ workdir: None,
521
+ }
522
+ }
523
+
524
+ fn strings(values: &[&str]) -> Vec<String> {
525
+ values.iter().map(|value| value.to_string()).collect()
526
+ }
527
+
449
528
  #[test]
450
529
  fn test_multistage() {
451
530
  let dockerfile = r#"
@@ -557,6 +636,46 @@ CMD ["uvicorn", "--host", "0.0.0.0", "--port", "5000", "app.main:app"]"#;
557
636
  args,
558
637
  labels,
559
638
  env_vars,
639
+ stages: vec![
640
+ models::Stage {
641
+ labels: BTreeMap::from([
642
+ ("org.opencontainers.image.title".into(), "My App".into()),
643
+ ("org.opencontainers.image.version".into(), "1.0".into()),
644
+ (
645
+ "org.opencontainers.image.authors".into(),
646
+ "john@example.com".into(),
647
+ ),
648
+ ]),
649
+ env_vars: BTreeMap::from([
650
+ ("PYTHONPATH".into(), "/src".into()),
651
+ ("PYTHONUNBUFFERED".into(), "1".into()),
652
+ (
653
+ "REQUESTS_CA_BUNDLE".into(),
654
+ "/etc/ssl/certs/ca-certificates.crt".into(),
655
+ ),
656
+ ("PATH".into(), "/home/appuser/.local/bin:$PATH".into()),
657
+ ]),
658
+ workdir: Some("/src".into()),
659
+ user: Some("root:root".into()),
660
+ ..stage(
661
+ 0,
662
+ Some("base"),
663
+ "docker.abc.com/base-images/python:3.13-debian@sha256:55f1d15ef4c37870e23c03e89ad238940b55c8ede9f13fac4b7d71c7955f1053",
664
+ None,
665
+ )
666
+ },
667
+ models::Stage {
668
+ user: Some("1000:1000".into()),
669
+ ..stage(1, Some("test"), "base", Some("base"))
670
+ },
671
+ models::Stage {
672
+ user: Some("1000:1000".into()),
673
+ args: BTreeMap::from([("GIT_COMMIT".into(), None)]),
674
+ env_vars: BTreeMap::from([("GIT_COMMIT".into(), "$GIT_COMMIT".into())]),
675
+ exposed_ports: strings(&["5000"]),
676
+ ..stage(2, None, "base", Some("base"))
677
+ },
678
+ ],
560
679
  };
561
680
 
562
681
  let res = analyze_dockerfile(dockerfile);
@@ -645,6 +764,13 @@ CMD ["npm", "start"]
645
764
  args: BTreeMap::new(),
646
765
  labels: BTreeMap::new(),
647
766
  env_vars: BTreeMap::from([("NODE_ENV".into(), "production".into())]),
767
+ stages: vec![models::Stage {
768
+ workdir: Some("/app".into()),
769
+ user: Some("nextjs".into()),
770
+ exposed_ports: strings(&["3000"]),
771
+ env_vars: BTreeMap::from([("NODE_ENV".into(), "production".into())]),
772
+ ..stage(0, None, "node:20-alpine", None)
773
+ }],
648
774
  };
649
775
  let res = analyze_dockerfile(dockerfile);
650
776
  assert!(res.is_ok());
@@ -765,6 +891,26 @@ CMD ["node", "server.js"]
765
891
  args: BTreeMap::new(),
766
892
  labels: BTreeMap::new(),
767
893
  env_vars: BTreeMap::new(),
894
+ stages: vec![
895
+ models::Stage {
896
+ workdir: Some("/app".into()),
897
+ ..stage(0, Some("dependencies"), "node:20-alpine", None)
898
+ },
899
+ models::Stage {
900
+ workdir: Some("/app".into()),
901
+ ..stage(1, Some("builder"), "node:20-alpine", None)
902
+ },
903
+ models::Stage {
904
+ workdir: Some("/configs".into()),
905
+ ..stage(2, Some("config-builder"), "alpine:3.18", None)
906
+ },
907
+ models::Stage {
908
+ workdir: Some("/app".into()),
909
+ user: Some("nextjs".into()),
910
+ exposed_ports: strings(&["8080"]),
911
+ ..stage(3, Some("production"), "node:20-alpine", None)
912
+ },
913
+ ],
768
914
  };
769
915
  let res = analyze_dockerfile(dockerfile);
770
916
  assert!(res.is_ok());
@@ -868,6 +1014,26 @@ CMD ["./app"]
868
1014
  args: BTreeMap::new(),
869
1015
  labels: BTreeMap::new(),
870
1016
  env_vars: BTreeMap::new(),
1017
+ stages: vec![
1018
+ models::Stage {
1019
+ workdir: Some("/downloads".into()),
1020
+ ..stage(0, Some("downloader"), "alpine:3.18", None)
1021
+ },
1022
+ models::Stage {
1023
+ workdir: Some("/src".into()),
1024
+ ..stage(1, Some("go-builder"), "golang:1.21-alpine", None)
1025
+ },
1026
+ models::Stage {
1027
+ workdir: Some("/certs".into()),
1028
+ ..stage(2, Some("cert-generator"), "alpine:3.18", None)
1029
+ },
1030
+ models::Stage {
1031
+ workdir: Some("/app".into()),
1032
+ user: Some("appuser".into()),
1033
+ exposed_ports: strings(&["8080", "8443"]),
1034
+ ..stage(3, None, "alpine:3.18", None)
1035
+ },
1036
+ ],
871
1037
  };
872
1038
  let res = analyze_dockerfile(dockerfile);
873
1039
  assert!(res.is_ok());
@@ -1115,4 +1281,223 @@ FROM ${REG}/library/ubuntu:${TAG:-22.04} AS build
1115
1281
  assert!(!rendered.contains("components=\"ImageComponents"));
1116
1282
  assert!(!rendered.contains("Some("));
1117
1283
  }
1284
+
1285
+ #[test]
1286
+ fn test_stage_parent_resolves_only_earlier_stages() {
1287
+ let dockerfile = r#"
1288
+ FROM alpine:3.20 AS Base
1289
+ FROM base AS child
1290
+ FROM later AS early
1291
+ FROM node:20 AS later
1292
+ FROM unknown-image
1293
+ FROM child
1294
+ "#;
1295
+ let analysis = analyze_dockerfile(dockerfile).unwrap();
1296
+ assert_eq!(
1297
+ analysis.stages,
1298
+ vec![
1299
+ stage(0, Some("base"), "alpine:3.20", None),
1300
+ stage(1, Some("child"), "base", Some("base")),
1301
+ // `later` is defined after this stage, so it is an image.
1302
+ stage(2, Some("early"), "later", None),
1303
+ stage(3, Some("later"), "node:20", None),
1304
+ stage(4, None, "unknown-image", None),
1305
+ stage(5, None, "child", Some("child")),
1306
+ ]
1307
+ );
1308
+ assert_eq!(analysis.num_stages, analysis.stages.len());
1309
+ }
1310
+
1311
+ #[test]
1312
+ fn test_stage_platform() {
1313
+ let dockerfile = r#"
1314
+ FROM --platform=$BUILDPLATFORM golang:1.22 AS build
1315
+ FROM --platform=linux/amd64 alpine:3.20 AS runtime
1316
+ FROM scratch
1317
+ "#;
1318
+ let analysis = analyze_dockerfile(dockerfile).unwrap();
1319
+ let platforms: Vec<_> = analysis
1320
+ .stages
1321
+ .iter()
1322
+ .map(|stage| stage.platform.clone())
1323
+ .collect();
1324
+ assert_eq!(
1325
+ platforms,
1326
+ vec![
1327
+ Some("$BUILDPLATFORM".to_string()),
1328
+ Some("linux/amd64".to_string()),
1329
+ None
1330
+ ]
1331
+ );
1332
+ }
1333
+
1334
+ #[test]
1335
+ fn test_stage_scoped_values_and_merged_top_level() {
1336
+ let dockerfile = r#"
1337
+ FROM golang:1.22 AS build
1338
+ ARG TARGETOS=linux
1339
+ ENV CGO_ENABLED=0 MODE=build
1340
+ LABEL stage="build"
1341
+ EXPOSE 6060
1342
+ WORKDIR /src
1343
+ USER builder
1344
+ WORKDIR /src/cmd
1345
+
1346
+ FROM alpine:3.20
1347
+ ARG TARGETOS
1348
+ ENV MODE=runtime
1349
+ LABEL stage="runtime" org.opencontainers.image.title="app"
1350
+ EXPOSE 8080/tcp 9090/udp
1351
+ USER root
1352
+ USER 65532:65532
1353
+ "#;
1354
+ let analysis = analyze_dockerfile(dockerfile).unwrap();
1355
+ let build = &analysis.stages[0];
1356
+ let runtime = &analysis.stages[1];
1357
+
1358
+ assert_eq!(
1359
+ build.args,
1360
+ BTreeMap::from([("TARGETOS".into(), Some("linux".into()))])
1361
+ );
1362
+ assert_eq!(
1363
+ build.env_vars,
1364
+ BTreeMap::from([
1365
+ ("CGO_ENABLED".into(), "0".into()),
1366
+ ("MODE".into(), "build".into()),
1367
+ ])
1368
+ );
1369
+ assert_eq!(
1370
+ build.labels,
1371
+ BTreeMap::from([("stage".into(), "build".into())])
1372
+ );
1373
+ assert_eq!(build.exposed_ports, strings(&["6060"]));
1374
+ assert_eq!(build.user.as_deref(), Some("builder"));
1375
+ assert_eq!(build.workdir.as_deref(), Some("/src/cmd"));
1376
+
1377
+ assert_eq!(runtime.args, BTreeMap::from([("TARGETOS".into(), None)]));
1378
+ assert_eq!(
1379
+ runtime.env_vars,
1380
+ BTreeMap::from([("MODE".into(), "runtime".into())])
1381
+ );
1382
+ assert_eq!(
1383
+ runtime.labels,
1384
+ BTreeMap::from([
1385
+ ("org.opencontainers.image.title".into(), "app".into()),
1386
+ ("stage".into(), "runtime".into()),
1387
+ ])
1388
+ );
1389
+ assert_eq!(runtime.exposed_ports, strings(&["8080/tcp", "9090/udp"]));
1390
+ assert_eq!(runtime.user.as_deref(), Some("65532:65532"));
1391
+ assert_eq!(runtime.workdir, None);
1392
+
1393
+ // Top-level values stay merged across stages.
1394
+ assert_eq!(
1395
+ analysis.args,
1396
+ BTreeMap::from([("TARGETOS".into(), Some("linux".into()))])
1397
+ );
1398
+ assert_eq!(
1399
+ analysis.env_vars,
1400
+ BTreeMap::from([
1401
+ ("CGO_ENABLED".into(), "0".into()),
1402
+ ("MODE".into(), "runtime".into()),
1403
+ ])
1404
+ );
1405
+ assert_eq!(
1406
+ analysis.labels,
1407
+ BTreeMap::from([
1408
+ ("org.opencontainers.image.title".into(), "app".into()),
1409
+ ("stage".into(), "runtime".into()),
1410
+ ])
1411
+ );
1412
+ assert_eq!(
1413
+ analysis.exposed_ports,
1414
+ strings(&["6060", "8080/tcp", "9090/udp"])
1415
+ );
1416
+ }
1417
+
1418
+ #[test]
1419
+ fn test_global_args_are_not_attributed_to_first_stage() {
1420
+ let dockerfile = r#"
1421
+ ARG VERSION=1
1422
+ ARG REGISTRY=docker.io
1423
+ FROM ${REGISTRY}/alpine:${VERSION} AS base
1424
+ RUN true
1425
+
1426
+ FROM base
1427
+ ARG VERSION
1428
+ "#;
1429
+ let analysis = analyze_dockerfile(dockerfile).unwrap();
1430
+ assert!(analysis.stages[0].args.is_empty());
1431
+ assert_eq!(
1432
+ analysis.stages[0].base_image,
1433
+ "${REGISTRY}/alpine:${VERSION}"
1434
+ );
1435
+ assert_eq!(
1436
+ analysis.stages[1].args,
1437
+ BTreeMap::from([("VERSION".into(), None)])
1438
+ );
1439
+ assert_eq!(
1440
+ analysis.args,
1441
+ BTreeMap::from([
1442
+ ("REGISTRY".into(), Some("docker.io".into())),
1443
+ ("VERSION".into(), Some("1".into())),
1444
+ ])
1445
+ );
1446
+ }
1447
+
1448
+ #[test]
1449
+ fn test_final_stage_info_is_last_stage() {
1450
+ let dockerfile = r#"
1451
+ FROM golang:1.22 AS build
1452
+ FROM build AS release
1453
+ USER app
1454
+ "#;
1455
+ let analysis = analyze_dockerfile(dockerfile).unwrap();
1456
+ assert_eq!(analysis.final_stage, Some("release".to_string()));
1457
+ assert_eq!(
1458
+ analysis.final_stage_info(),
1459
+ Some(models::Stage {
1460
+ user: Some("app".into()),
1461
+ ..stage(1, Some("release"), "build", Some("build"))
1462
+ })
1463
+ );
1464
+
1465
+ let unnamed = analyze_dockerfile("FROM alpine AS a\nFROM scratch\n").unwrap();
1466
+ assert_eq!(unnamed.final_stage, None);
1467
+ assert_eq!(
1468
+ unnamed.final_stage_info(),
1469
+ Some(stage(1, None, "scratch", None))
1470
+ );
1471
+ }
1472
+
1473
+ #[test]
1474
+ fn test_final_stage_info_without_stages_is_none() {
1475
+ let mut analysis = analyze_dockerfile("FROM alpine\n").unwrap();
1476
+ analysis.stages.clear();
1477
+ assert_eq!(analysis.final_stage_info(), None);
1478
+ }
1479
+
1480
+ #[test]
1481
+ fn test_bare_arg_in_same_stage_clears_default() {
1482
+ let dockerfile = "FROM alpine\nARG VERSION=1\nARG VERSION\n";
1483
+ let analysis = analyze_dockerfile(dockerfile).unwrap();
1484
+ assert_eq!(
1485
+ analysis.stages[0].args,
1486
+ BTreeMap::from([("VERSION".into(), None)])
1487
+ );
1488
+ assert_eq!(
1489
+ analysis.args,
1490
+ BTreeMap::from([("VERSION".into(), Some("1".into()))])
1491
+ );
1492
+ }
1493
+
1494
+ #[test]
1495
+ fn test_stage_repr_uses_python_none() {
1496
+ let analysis = analyze_dockerfile("FROM alpine\nARG VERSION\n").unwrap();
1497
+ assert_eq!(
1498
+ analysis.stages[0].repr(),
1499
+ "Stage(index=0, name=None, base_image=\"alpine\", parent_stage=None, platform=None, args={\"VERSION\": None}, env_vars={}, labels={}, exposed_ports=[], user=None, workdir=None)"
1500
+ );
1501
+ assert!(!analysis.repr().contains("Stage("));
1502
+ }
1118
1503
  }
@@ -11,6 +11,7 @@ pub const HEALTHCHECK: &str = "HEALTHCHECK";
11
11
  pub const LABEL: &str = "LABEL";
12
12
  pub const MAINTAINER: &str = "MAINTAINER";
13
13
  pub const ONBUILD: &str = "ONBUILD";
14
+ pub const PLATFORM: &str = "platform";
14
15
  pub const RUN: &str = "RUN";
15
16
  pub const SHELL: &str = "SHELL";
16
17
  pub const STOPSIGNAL: &str = "STOPSIGNAL";
@@ -22,6 +22,10 @@ Returns:
22
22
  only; `final_stage` is the last stage name, or None when it is unnamed.
23
23
  A later `ARG NAME` without a value keeps an earlier default.
24
24
 
25
+ `args`, `labels`, `env_vars`, and `exposed_ports` are merged across all
26
+ stages. `stages` lists each stage with the values it sets itself, and
27
+ `final_stage_info` is the last `Stage` (None when there are no stages).
28
+
25
29
  Raises:
26
30
  ValueError: If the dockerfile content is empty or invalid
27
31
 
@@ -47,5 +51,6 @@ fn dockerfile_analyzer(m: &Bound<'_, PyModule>) -> PyResult<()> {
47
51
  m.add_class::<models::Image>()?;
48
52
  m.add_class::<models::ImageComponents>()?;
49
53
  m.add_class::<models::InstructionStats>()?;
54
+ m.add_class::<models::Stage>()?;
50
55
  Ok(())
51
56
  }
@@ -181,6 +181,98 @@ impl MultistageAnalysis {
181
181
  }
182
182
  }
183
183
 
184
+ #[pyclass(skip_from_py_object)]
185
+ #[doc = "One build stage, from its `FROM` line to the next `FROM`.
186
+
187
+ Values are the stage's own, as written in that stage. Nothing is inherited
188
+ from a parent stage, and global `ARG`s declared before the first `FROM` are
189
+ not attributed to any stage.
190
+
191
+ Attributes:
192
+ index (int): Zero-based position of the stage in the Dockerfile.
193
+ name (str | None): The lowercased `AS` name, or None when unnamed.
194
+ base_image (str): The `FROM` reference. It is lowercased unless it
195
+ contains `$`, matching `Analysis.images`.
196
+ parent_stage (str | None): The name of the earlier stage this `FROM`
197
+ builds on, or None. Only earlier stages are matched, as in Docker; a
198
+ name defined later, or any other name, is treated as an image.
199
+ platform (str | None): The `--platform` value as written, or None.
200
+ args (dict[str, str | None]): `ARG`s declared in this stage. A
201
+ redeclaration without a default is None here.
202
+ env_vars (dict[str, str]): `ENV` values set in this stage.
203
+ labels (dict[str, str]): `LABEL` values set in this stage.
204
+ exposed_ports (list[str]): `EXPOSE` tokens in this stage, sorted.
205
+ user (str | None): The last `USER` in this stage, or None.
206
+ workdir (str | None): The last `WORKDIR` in this stage, or None.
207
+ "]
208
+ #[derive(Debug, PartialEq, Clone)]
209
+ pub struct Stage {
210
+ #[pyo3(get)]
211
+ pub index: usize,
212
+ #[pyo3(get)]
213
+ pub name: Option<String>,
214
+ #[pyo3(get)]
215
+ pub base_image: String,
216
+ #[pyo3(get)]
217
+ pub parent_stage: Option<String>,
218
+ #[pyo3(get)]
219
+ pub platform: Option<String>,
220
+ #[pyo3(get)]
221
+ pub args: BTreeMap<String, Option<String>>,
222
+ #[pyo3(get)]
223
+ pub env_vars: BTreeMap<String, String>,
224
+ #[pyo3(get)]
225
+ pub labels: BTreeMap<String, String>,
226
+ #[pyo3(get)]
227
+ pub exposed_ports: Vec<String>,
228
+ #[pyo3(get)]
229
+ pub user: Option<String>,
230
+ #[pyo3(get)]
231
+ pub workdir: Option<String>,
232
+ }
233
+
234
+ impl Stage {
235
+ pub fn repr(&self) -> String {
236
+ self.__repr__()
237
+ }
238
+ }
239
+
240
+ #[pymethods]
241
+ impl Stage {
242
+ fn __repr__(&self) -> String {
243
+ format!(
244
+ "Stage(index={}, name={}, base_image={:?}, parent_stage={}, platform={}, args={}, env_vars={:?}, labels={:?}, exposed_ports={:?}, user={}, workdir={})",
245
+ self.index,
246
+ fmt_opt(&self.name),
247
+ self.base_image,
248
+ fmt_opt(&self.parent_stage),
249
+ fmt_opt(&self.platform),
250
+ fmt_opt_map(&self.args),
251
+ self.env_vars,
252
+ self.labels,
253
+ self.exposed_ports,
254
+ fmt_opt(&self.user),
255
+ fmt_opt(&self.workdir)
256
+ )
257
+ }
258
+
259
+ fn to_dict(&self, py: Python) -> PyResult<Py<PyAny>> {
260
+ let dict = PyDict::new(py);
261
+ dict.set_item("index", self.index)?;
262
+ dict.set_item("name", &self.name)?;
263
+ dict.set_item("base_image", &self.base_image)?;
264
+ dict.set_item("parent_stage", &self.parent_stage)?;
265
+ dict.set_item("platform", &self.platform)?;
266
+ dict.set_item("args", &self.args)?;
267
+ dict.set_item("env_vars", &self.env_vars)?;
268
+ dict.set_item("labels", &self.labels)?;
269
+ dict.set_item("exposed_ports", &self.exposed_ports)?;
270
+ dict.set_item("user", &self.user)?;
271
+ dict.set_item("workdir", &self.workdir)?;
272
+ Ok(dict.into())
273
+ }
274
+ }
275
+
184
276
  #[pyclass(skip_from_py_object)]
185
277
  #[doc = "Analysis of one Dockerfile.
186
278
 
@@ -199,6 +291,13 @@ in-range index of an unnamed stage, the index is reported in place of a name.
199
291
  images are `copy_from_images` and `add_from_images`. A `--from` value that
200
292
  names a stage, or an in-range stage index, is a stage reference rather than
201
293
  an image.
294
+
295
+ `args`, `labels`, `env_vars`, and `exposed_ports` are merged across all
296
+ stages (and, for `args`, global `ARG`s before the first `FROM`); a later
297
+ stage's value for a key replaces an earlier one. Use `stages` for the values
298
+ each stage sets. `stages` lists every stage in Dockerfile order.
299
+ `final_stage_info` is the last `Stage`, or None when there are no stages;
300
+ `final_stage` keeps returning only the last stage's name.
202
301
  "]
203
302
  #[derive(Debug, PartialEq, Clone)]
204
303
  pub struct Analysis {
@@ -230,6 +329,8 @@ pub struct Analysis {
230
329
  pub labels: BTreeMap<String, String>,
231
330
  #[pyo3(get)]
232
331
  pub env_vars: BTreeMap<String, String>,
332
+ #[pyo3(get)]
333
+ pub stages: Vec<Stage>,
233
334
  }
234
335
 
235
336
  impl Analysis {
@@ -240,6 +341,11 @@ impl Analysis {
240
341
 
241
342
  #[pymethods]
242
343
  impl Analysis {
344
+ #[getter]
345
+ pub fn final_stage_info(&self) -> Option<Stage> {
346
+ self.stages.last().cloned()
347
+ }
348
+
243
349
  fn __repr__(&self) -> String {
244
350
  let images = join_images(&self.images);
245
351
  let copy_from_images = join_images(&self.copy_from_images);
@@ -282,6 +388,12 @@ impl Analysis {
282
388
  dict.set_item("args", &self.args)?;
283
389
  dict.set_item("labels", &self.labels)?;
284
390
  dict.set_item("env_vars", &self.env_vars)?;
391
+ let stages = self
392
+ .stages
393
+ .iter()
394
+ .map(|stage| stage.to_dict(py))
395
+ .collect::<PyResult<Vec<_>>>()?;
396
+ dict.set_item("stages", stages)?;
285
397
  Ok(dict.into())
286
398
  }
287
399
  }
@@ -0,0 +1,192 @@
1
+ """Tests for the per-stage breakdown (Analysis.stages and final_stage_info)."""
2
+
3
+ from pathlib import Path
4
+
5
+ import dockerfile_analyzer as da
6
+
7
+ FIXTURES = Path(__file__).parent / "fixtures"
8
+
9
+ STAGE_KEYS = {
10
+ "index",
11
+ "name",
12
+ "base_image",
13
+ "parent_stage",
14
+ "platform",
15
+ "args",
16
+ "env_vars",
17
+ "labels",
18
+ "exposed_ports",
19
+ "user",
20
+ "workdir",
21
+ }
22
+
23
+
24
+ def test_multistage_fixture_stages():
25
+ analysis = da.analyze_dockerfile((FIXTURES / "multistage.Dockerfile").read_text())
26
+ base, test, final = analysis.stages
27
+
28
+ assert [stage.index for stage in analysis.stages] == [0, 1, 2]
29
+ assert [stage.name for stage in analysis.stages] == ["base", "test", None]
30
+ assert base.base_image.startswith("docker.abc.com/base-images/python:3.13-debian@")
31
+ assert base.parent_stage is None
32
+ assert test.base_image == "base"
33
+ assert test.parent_stage == "base"
34
+ assert final.parent_stage == "base"
35
+
36
+ assert base.labels["org.opencontainers.image.title"] == "My App"
37
+ assert base.env_vars["PYTHONPATH"] == "/src"
38
+ assert "GIT_COMMIT" not in base.env_vars
39
+ assert base.workdir == "/src"
40
+ assert base.user == "root:root"
41
+
42
+ assert test.env_vars == {}
43
+ assert test.labels == {}
44
+ assert test.user == "1000:1000"
45
+ assert test.workdir is None
46
+
47
+ assert final.args == {"GIT_COMMIT": None}
48
+ assert final.env_vars == {"GIT_COMMIT": "$GIT_COMMIT"}
49
+ assert final.exposed_ports == ["5000"]
50
+ assert final.platform is None
51
+
52
+ # Top-level values remain merged across stages.
53
+ assert set(analysis.env_vars) == set(base.env_vars) | set(final.env_vars)
54
+ assert analysis.final_stage is None
55
+ assert analysis.final_stage_info.to_dict() == final.to_dict()
56
+
57
+
58
+ def test_parent_stage_only_resolves_earlier_stages():
59
+ dockerfile = """\
60
+ FROM alpine:3.20 AS Base
61
+ FROM base AS child
62
+ FROM later AS early
63
+ FROM node:20 AS later
64
+ FROM unknown-image
65
+ FROM child
66
+ """
67
+ stages = da.analyze_dockerfile(dockerfile).stages
68
+
69
+ assert [(s.name, s.base_image, s.parent_stage) for s in stages] == [
70
+ ("base", "alpine:3.20", None),
71
+ ("child", "base", "base"),
72
+ ("early", "later", None),
73
+ ("later", "node:20", None),
74
+ (None, "unknown-image", None),
75
+ (None, "child", "child"),
76
+ ]
77
+
78
+
79
+ def test_platform_per_stage():
80
+ dockerfile = """\
81
+ FROM --platform=$BUILDPLATFORM golang:1.22 AS build
82
+ FROM --platform=linux/amd64 alpine:3.20
83
+ """
84
+ stages = da.analyze_dockerfile(dockerfile).stages
85
+ assert [s.platform for s in stages] == ["$BUILDPLATFORM", "linux/amd64"]
86
+
87
+
88
+ def test_per_stage_values_and_overrides():
89
+ dockerfile = """\
90
+ FROM golang:1.22 AS build
91
+ ARG TARGETOS=linux
92
+ ENV MODE=build
93
+ LABEL stage=build
94
+ EXPOSE 6060
95
+ WORKDIR /src
96
+ WORKDIR /src/cmd
97
+ USER builder
98
+
99
+ FROM alpine:3.20
100
+ ENV MODE=runtime
101
+ LABEL stage=runtime
102
+ EXPOSE 8080/tcp
103
+ USER root
104
+ USER 65532
105
+ """
106
+ analysis = da.analyze_dockerfile(dockerfile)
107
+ build, runtime = analysis.stages
108
+
109
+ assert build.args == {"TARGETOS": "linux"}
110
+ assert build.env_vars == {"MODE": "build"}
111
+ assert build.labels == {"stage": "build"}
112
+ assert build.exposed_ports == ["6060"]
113
+ assert build.workdir == "/src/cmd"
114
+ assert build.user == "builder"
115
+
116
+ assert runtime.args == {}
117
+ assert runtime.env_vars == {"MODE": "runtime"}
118
+ assert runtime.labels == {"stage": "runtime"}
119
+ assert runtime.exposed_ports == ["8080/tcp"]
120
+ assert runtime.workdir is None
121
+ assert runtime.user == "65532"
122
+
123
+ assert analysis.env_vars == {"MODE": "runtime"}
124
+ assert analysis.labels == {"stage": "runtime"}
125
+ assert analysis.exposed_ports == ["6060", "8080/tcp"]
126
+ assert analysis.args == {"TARGETOS": "linux"}
127
+
128
+
129
+ def test_global_args_not_attributed_to_first_stage():
130
+ dockerfile = """\
131
+ ARG VERSION=1
132
+ FROM alpine:${VERSION} AS base
133
+ FROM base
134
+ ARG VERSION
135
+ """
136
+ analysis = da.analyze_dockerfile(dockerfile)
137
+ assert analysis.stages[0].args == {}
138
+ assert analysis.stages[1].args == {"VERSION": None}
139
+ assert analysis.args == {"VERSION": "1"}
140
+
141
+
142
+ def test_bare_arg_in_same_stage_clears_default():
143
+ analysis = da.analyze_dockerfile("FROM alpine\nARG VERSION=1\nARG VERSION\n")
144
+ assert analysis.stages[0].args == {"VERSION": None}
145
+ assert analysis.args == {"VERSION": "1"}
146
+
147
+
148
+ def test_final_stage_info_and_final_stage_name():
149
+ analysis = da.analyze_dockerfile("FROM golang:1.22 AS build\nFROM build AS release\n")
150
+ assert analysis.final_stage == "release"
151
+ final = analysis.final_stage_info
152
+ assert isinstance(final, da.Stage)
153
+ assert final.index == 1
154
+ assert final.name == "release"
155
+ assert final.parent_stage == "build"
156
+
157
+
158
+ def test_to_dict_adds_only_stages_key():
159
+ analysis = da.analyze_dockerfile((FIXTURES / "multistage.Dockerfile").read_text())
160
+ data = analysis.to_dict()
161
+
162
+ assert list(data) == [
163
+ "num_stages",
164
+ "images",
165
+ "stage_names",
166
+ "final_stage",
167
+ "copy_from_stages",
168
+ "add_from_stages",
169
+ "copy_from_images",
170
+ "add_from_images",
171
+ "multistage_analysis",
172
+ "exposed_ports",
173
+ "instructions",
174
+ "args",
175
+ "labels",
176
+ "env_vars",
177
+ "stages",
178
+ ]
179
+ assert len(data["stages"]) == 3
180
+ assert all(set(stage) == STAGE_KEYS for stage in data["stages"])
181
+ assert data["stages"][2] == analysis.stages[2].to_dict()
182
+ assert data["stages"][1]["parent_stage"] == "base"
183
+ assert data["stages"][2]["name"] is None
184
+
185
+
186
+ def test_stage_repr():
187
+ stage = da.analyze_dockerfile("FROM alpine\nARG VERSION\n").stages[0]
188
+ assert repr(stage) == (
189
+ 'Stage(index=0, name=None, base_image="alpine", parent_stage=None, '
190
+ 'platform=None, args={"VERSION": None}, env_vars={}, labels={}, '
191
+ "exposed_ports=[], user=None, workdir=None)"
192
+ )