Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

introducing project mode #1022

Closed
wants to merge 2 commits into from
Closed

introducing project mode #1022

wants to merge 2 commits into from

Conversation

rafalkrupinski
Copy link
Contributor

  1. First commit extracts a Renderer class from Worker.
    Not only Worker is huge and does a lot, but the new class greatly simplifies rendering the template for each project-mode item in the next commit.
    The idea behind it was that it encapsulates the template and accepts answers to render the output.
    I decided to leave _allow_render in Worker and pass it as a function argument, as it uses data stored in Worker. Let me know if you can think of a better way to split responsibility between those two.
  2. Add looping over items
  • accept _items in copier.yaml as either a string or a list of strings. Internally it's always a list.
  • use each key to read a list of items from answers, and render its part of the template, stored under {{template_root}}/{{key}}
    So if you have _items: ['a'], copier expects an answer a to be a list of values, and uses each value to render a sub-template stored in 'a'.
  • all directories under root ,that are named like the keys under _items are excluded from normal rendering.
  1. Not sure how to structure the documentation. Needs changes to both the template and configuration parts. Shell I create a new file for the project mode?
  2. If nobody minds, I'd like to disable the "identical" message for directories. I'm getting it a hundred of times when run in a loop and it doesn't do anything useful for directories.

example template: https://github.com/python-lapidary/lapidary-template/tree/test2

@rafalkrupinski
Copy link
Contributor Author

For me these tests fail on master as well

@sisp
Copy link
Member

sisp commented Mar 5, 2023

They pass for me on master, and CI seems happy, too. 🤔

Copy link
Member

@sisp sisp left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A couple of thoughts from my side:

  1. If you think that the main.Worker class does too much and it would be better to factor out the rendering methods into renderer.Renderer, I suggest to do this refactoring in a dedicated PR to separate concerns and ease the review. In any case, @yajo will need to decide whether he likes the refactoring.
  2. From a template creator's perspective, I find it a bit unexpected that the files in the folder marked via _items are merged in the target folder. For instance, in test_render_items_list() the files in both src/apples/ and src/oranges/ are generated to <dst>/ and not to <dst>/apples/ and <dst>/oranges/, respectively. I imagine there are technical reasons for this behavior, but the point of declarative project templates is to make the them readable and close to the generated project. Arguably, generating a directory structure is also in violation of this goal, but in that case the benefits outweigh the loss in readability IMO, and at least there is no implicit magic unlike the implicit merging of the src/apples/ and src/oranges/ folders.
  3. I'm a bit confused by test_render_items_conditional(). The behavior tested there seems to equivalent to using a dynamic subdirectory. The only difference is the usage of the new special variable _item. Is the goal of this test case to test the rendering of dict items?
  4. There was some minor discussion about nested loops in Making Copier generate a complete project #908. Would nested loops be possible with the current design? If yes, could you add a test case? I am vaguely worried that the current design might not be sufficiently flexible to address more advanced cases because the rendering context is only the main context plus the current item's data, but with nested loops the context from the parent loop seems to be unavailable in a child loop.

@rafalkrupinski
Copy link
Contributor Author

A couple of thoughts from my side:

1. If you think that the `main.Worker` class does too much and it would be better to factor out the rendering methods into `renderer.Renderer`, I suggest to do this refactoring in a dedicated PR to separate concerns and ease the review. In any case, @yajo will need to decide whether he likes the refactoring.

Yeah, I thought it's easier to change from a single PR to multiple, than the other way around

2. From a template creator's perspective, I find it a bit unexpected that the files in the folder marked via `_items` are merged in the target folder. For instance, in `test_render_items_list()` the files in both `src/apples/` and `src/oranges/` are generated to `<dst>/` and not to `<dst>/apples/` and `<dst>/oranges/`, respectively. I imagine there are technical reasons for this behavior, but the point of declarative project templates is to make the them readable and close to the generated project. Arguably, [generating a directory structure](https://copier.readthedocs.io/en/latest/configuring/#generating-a-directory-structure) is also in violation of this goal, but in that case the benefits outweigh the loss in readability IMO, and at least there is no implicit magic unlike the implicit merging of the `src/apples/` and `src/oranges/` folders.

The first version I did was with only one items list and without the directory. In result, all files and directories under the template root had to be named with a condition ({% if _item %} or {% if not _item %}). I found that hard to read.
With multiple item lists it gets slightly worse.

Those directories are already treated specially - their contents is rendered once for every item. I would find it weird if they were copied over to the output, but I guess it's just a matter of expectations of a new user of Copier vs a seasoned one.

I'd rather not add it as a flag, bc removing the directories IMO would be more reasonable default, but not what older users might expect.

How about those directories go to _subtemplates directory to signify their special treatment?
e.g. src/_subtemplates/apples/* -> dst/*

3. I'm a bit confused by `test_render_items_conditional()`. The behavior tested there seems to equivalent to using a dynamic [subdirectory](https://copier.readthedocs.io/en/latest/configuring/#subdirectory). The only difference is the usage of the new special variable `_item`. Is the goal of this test case to test the rendering of dict items?

Good catch. It's from the previous version, updated a bit thoughtlessly.

4. There was some minor discussion about nested loops in #908. Would nested loops be possible with the current design? If yes, could you add a test case? I am vaguely worried that the current design might not be sufficiently flexible to address more advanced cases because the rendering context is only the main context plus the current item's data, but with nested loops the context from the parent loop seems to be unavailable in a child loop.

You're referring to this. I never intended to implement it due to exponential complexity of such templates.

Considering the goal of this change (generating SDK or class model from a machine readable description), I'd say nested loops are out of scope. If you're generating a class model, you have a single list of table definitions or JSON schemas to generate classes for - that's a single list. In case of SDK for OpenAPI, you have a bunch of schemas and operations, that's two lists, processed in parallel. All that assuming your model is large enough to render it as multiple files, otherwise you don't even need project mode.

Do you have a use case in mind?

@rafalkrupinski
Copy link
Contributor Author

For me these tests fail on master as well

The tests mostly pass on an identical environment in my fork.
https://github.com/python-lapidary/copier/actions/runs/4333218244

@rafalkrupinski
Copy link
Contributor Author

@yajo Any idea how to progress with this?

Copy link
Member

@yajo yajo left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I find surprising also what @sisp said. I think that a looped template should behave as close as possible to a non-looped one.

My expectations are that it just gets rendered in loop under different context. Since the loop is sequential, any file that gets rendered more than once would get only the last result. It is up to the template designer to avoid that kind of situations; we just have to document that.

FWIW the normal thing would be that a file that gets rendered twice results in the same output if the context didn't change or _item isn't used within its content.

If it's needed to avoid that, then we can also add a key such as _dynamic_files with a list of gitignore-like patterns (just like _exclude) of files that should be rendered in loop. By default: all files. But that would be an extra thing to add later. I wouldn't include this in the MVP if possible.

So a simple design would be:

# src/copier.yaml
_items: "{{ classes }}"

classes:
  type: yaml
  help: write a list of classes you want to generate
  default: [one, two]

user: your name

Then another file named src/{{ _item }}.txt:

{{ user }}

I'd run copier -f src dst and expect to get:

dst/one.txt (contents: "your name")
dst/two.txt (contents: "your name")

Do you think you would be able to implement such design?

overwrite=True,
)

one_rendered = (dst / "one.txt").read_text()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why this isn't like this? 🤔

Suggested change
one_rendered = (dst / "one.txt").read_text()
one_rendered = (dst / "items" / "one.txt").read_text()

Comment on lines +12 to +16
/ "copier.yml": """
_items: items
""",
src
/ "items"
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are too many "items" things here! 😆

Please give each a different name to let me reason easily about what are you testing here. Something like this would be enough:

Suggested change
/ "copier.yml": """
_items: items
""",
src
/ "items"
/ "copier.yml": """
_items: items_var
""",
src
/ "items_dir"

@@ -0,0 +1,174 @@
from collections import ChainMap
from dataclasses import dataclass, field
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Probably you should change this to a pydantic dataclass, just to be consistent with the rest of Copier.

Comment on lines +164 to +174
return Renderer(
self.template,
self.subproject,
self._render_allowed,
self.pretend,
self.jinja_env,
self.answers_relpath,
ChainMap({"_item": items_value}, self._render_context),
items_key,
[],
)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can do this probably:

Suggested change
return Renderer(
self.template,
self.subproject,
self._render_allowed,
self.pretend,
self.jinja_env,
self.answers_relpath,
ChainMap({"_item": items_value}, self._render_context),
items_key,
[],
)
return replace(
self,
_render_context=ChainMap({"_item": items_value}, self._render_context),
_items_key=items_key,
_items_keys=[],
)

return cast(SandboxedEnvironment, self.abstract_jinja_env(SandboxedEnvironment))

@cached_property
def native_jinja_env(self) -> NativeEnvironment:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't see any uses of this...

Comment on lines +736 to +738
[raw_items]
if isinstance(raw_items, str)
else cast(collections.abc.Iterable, raw_items)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If it is a string, then it should probably get rendered using the native jinja env, to allow for complex scenarios. Right?

return self._default_renderer.render_string(string)

@cached_property
def project_mode_keys(self) -> Iterable[str]:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All have been projects since forever... let's give it a better name:

Suggested change
def project_mode_keys(self) -> Iterable[str]:
def looped_mode_keys(self) -> Iterable[str]:

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Call the file test_looped_mode.py

@yajo
Copy link
Member

yajo commented Apr 7, 2023

Oh and regarding the refactor... no problem with it.

@sisp
Copy link
Member

sisp commented Apr 7, 2023

@yajo Have you seen my comment in the other discussion about this topic? #908 (comment) I think that approach would cover a broader range of scenarios with more intuitive API. WDYT?

@yajo
Copy link
Member

yajo commented Apr 23, 2023 via email

@yajo
Copy link
Member

yajo commented Jan 15, 2024

Closing because the feature has been designed in #1271 and this PR doesn't follow that design.

Thanks @rafalkrupinski for raising this subject! We'll wait until @0x00zer0day (or some faster volunteer) is able to publish the feature.

@yajo yajo closed this Jan 15, 2024
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.

3 participants